Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27cbf5fa26 | ||
|
|
fc920b4d3c | ||
|
|
d604deb6a5 | ||
|
|
8cf40241cf | ||
|
|
72e96ec73f | ||
|
|
5d593a1700 | ||
|
|
81495ea530 | ||
|
|
494b53192d | ||
|
|
8c0f3e418e | ||
|
|
d2f548d0e2 | ||
|
|
7e36bb02c6 | ||
|
|
3a5e46cd9d | ||
|
|
757116dc54 | ||
|
|
05334d3e05 | ||
|
|
2d68c61f4a | ||
|
|
c6d8834a42 | ||
|
|
99d3b21334 | ||
|
|
e9a9d51ea3 | ||
|
|
0572eec472 | ||
|
|
c7f4d91a10 | ||
|
|
45a9092bbc | ||
|
|
2dfed26c1e | ||
|
|
5585583765 | ||
|
|
c4a761d071 | ||
|
|
736e152167 | ||
|
|
a2756f6c0a | ||
|
|
15c3474519 | ||
|
|
87382e650e | ||
|
|
f240aeaf05 | ||
|
|
911e9839a4 | ||
|
|
cc6d76da3f | ||
|
|
6bd6140ae9 | ||
|
|
433930d005 | ||
|
|
d381cc4b06 | ||
|
|
f52d4271dc | ||
|
|
6cf4990513 | ||
|
|
ffd0a5e709 | ||
|
|
845e03c877 | ||
|
|
de361d3fab | ||
|
|
548b63edc9 | ||
|
|
51286b3273 | ||
|
|
157606b082 | ||
|
|
2f40c921eb | ||
|
|
4b69068ace | ||
|
|
0e1b36f6e4 | ||
|
|
dafb7d879c | ||
|
|
6332ed1e4f | ||
|
|
d9e4a7c262 | ||
|
|
0765df3579 | ||
|
|
2b00a5970c | ||
|
|
22b6597ec7 | ||
|
|
c421675102 | ||
|
|
021b411e3c | ||
|
|
312e1575ae | ||
|
|
9e8cd23fe5 | ||
|
|
a769c0e496 | ||
|
|
99125a63d7 | ||
|
|
2768570660 | ||
|
|
12d6f99104 | ||
|
|
97a50ff452 | ||
|
|
b712394648 | ||
|
|
72d9a17f89 | ||
|
|
01de4f0f10 | ||
|
|
7f4b7b93f1 | ||
|
|
a3a0e3f95b | ||
|
|
890d868e16 | ||
|
|
530714e62b | ||
|
|
dd1d5d429f | ||
|
|
1f07d6eb13 | ||
|
|
e5b3edba43 | ||
|
|
4b53aca434 | ||
|
|
6981314fb5 | ||
|
|
b3c4270f6c | ||
|
|
54b525cfa0 | ||
|
|
06bb1b39d7 | ||
|
|
77fc32d760 | ||
|
|
d221ea479f | ||
|
|
b09400ecd6 | ||
|
|
685d6593fd | ||
|
|
4fc3cd84d0 | ||
|
|
88e75ac60c | ||
|
|
7bf39d9337 | ||
|
|
ea188af729 | ||
|
|
385e7b7778 | ||
|
|
e5cc122341 | ||
|
|
09227b216c | ||
|
|
8f388adc55 | ||
|
|
2f08c138ac | ||
|
|
6cbff32827 | ||
|
|
7e26211fa5 | ||
|
|
61288c5c2e | ||
|
|
22f7dedcf2 | ||
|
|
96c65a7d1b | ||
|
|
1224d35010 | ||
|
|
78f831ad69 | ||
|
|
acb5848da3 | ||
|
|
2c6de11c8c | ||
|
|
fe6f6ec451 | ||
|
|
0f4ba28105 | ||
|
|
727bd089a4 | ||
|
|
470133f953 | ||
|
|
388af28a2b | ||
|
|
7c18fcc208 | ||
|
|
4cf497b507 | ||
|
|
51eabadc36 | ||
|
|
96b4141aed | ||
|
|
963c36fc18 | ||
|
|
59e51d34c0 | ||
|
|
4e746ce2aa | ||
|
|
8d6d4e4b10 | ||
|
|
7f07f99951 | ||
|
|
e35ebb25a7 | ||
|
|
693f7191d0 | ||
|
|
2896e11d26 | ||
|
|
dd793c536e | ||
|
|
6c4589315a | ||
|
|
3f6b856eec | ||
|
|
bbc7556af2 | ||
|
|
5064f084a3 | ||
|
|
67761fbba8 | ||
|
|
630129bc32 | ||
|
|
ba170939e8 | ||
|
|
fd3c3805b6 | ||
|
|
7cb2da2468 | ||
|
|
aa1ae66821 | ||
|
|
f922a872cc | ||
|
|
23486ff1ef | ||
|
|
2fa4618ef9 | ||
|
|
50211e510b | ||
|
|
d7a72d7434 | ||
|
|
7d443ba00b | ||
|
|
1787b6041c | ||
|
|
fd4c501871 | ||
|
|
9fb653f7ee | ||
|
|
3f45b26396 | ||
|
|
44d0ca2261 | ||
|
|
699dedade0 | ||
|
|
a64e56d083 | ||
|
|
5a83e61fd1 | ||
|
|
c268e75c20 | ||
|
|
e63be54081 | ||
|
|
ecebd55654 | ||
|
|
78297c4179 | ||
|
|
bb5f6b8658 | ||
|
|
a059b48635 | ||
|
|
9ef1fb698a | ||
|
|
1b32a8a110 | ||
|
|
31b011b8fb | ||
|
|
a2e93fe7e4 | ||
|
|
1e42f9b5aa | ||
|
|
67199b5dba | ||
|
|
74c7331b7e | ||
|
|
00b735d32f | ||
|
|
a1fe6956d1 | ||
|
|
fe2423f5b4 | ||
|
|
6c20f53cc6 | ||
|
|
936a13913e | ||
|
|
290e7a81f5 | ||
|
|
327e7cc762 | ||
|
|
830776efd1 | ||
|
|
bdcf85ccd4 | ||
|
|
d42b7ca1d8 | ||
|
|
fcde3b31d6 | ||
|
|
ea4ed059c3 | ||
|
|
f670c3db76 | ||
|
|
af95f08ef4 | ||
|
|
70283ce4d3 | ||
|
|
bab87cd3c2 | ||
|
|
7545ade87d | ||
|
|
651ca65228 | ||
|
|
d524d142b5 | ||
|
|
da2239dca5 | ||
|
|
9d372b1d8f | ||
|
|
7aab09d6bc | ||
|
|
2295ce0f81 | ||
|
|
8ea15719bd | ||
|
|
d03936478c | ||
|
|
8dcef0f3d9 | ||
|
|
8912937a9a | ||
|
|
d4e6d0c112 | ||
|
|
696711dca1 | ||
|
|
b54302d546 | ||
|
|
0bd4382cf0 | ||
|
|
ed10b881b7 | ||
|
|
cbf102b8a0 | ||
|
|
be70a5f149 | ||
|
|
7d2bd63912 | ||
|
|
6f69304796 | ||
|
|
6802f11497 | ||
|
|
f888acdcd3 | ||
|
|
41fae1e41b | ||
|
|
bf61f649fa | ||
|
|
fc9d8a85a2 | ||
|
|
6f664ce9e9 | ||
|
|
5d715fba99 | ||
|
|
e98aca30b3 | ||
|
|
df76abdbb0 | ||
|
|
8568aa2c1e | ||
|
|
aba4e8c8ac | ||
|
|
ff6825cedd | ||
|
|
0ede205530 | ||
|
|
a738618dd3 | ||
|
|
8dcebdcc55 | ||
|
|
1b3e4aa61f | ||
|
|
0281b8e321 | ||
|
|
58c5b27922 | ||
|
|
4a34c9bbdd | ||
|
|
06c380fd01 | ||
|
|
c48aafb143 | ||
|
|
ff4d8faa1f | ||
|
|
c7c867e531 | ||
|
|
2fb853fa81 | ||
|
|
e1885a3115 | ||
|
|
f38a316ccb | ||
|
|
e56fd22bc9 | ||
|
|
b33afd8797 | ||
|
|
67a09df6d6 | ||
|
|
1f164df0c5 | ||
|
|
91b5f7581a | ||
|
|
3527ada397 | ||
|
|
25388b4650 | ||
|
|
6eb4b730cb | ||
|
|
942834d16a | ||
|
|
03796c56ba | ||
|
|
65683aed7c | ||
|
|
8a9dc9efbd | ||
|
|
8f696d6eec | ||
|
|
1822297a9a | ||
|
|
21f285033d | ||
|
|
bb5e84d3b2 | ||
|
|
b292699487 | ||
|
|
39fdb6f187 | ||
|
|
3c492f1cb9 | ||
|
|
79cf3857ad | ||
|
|
e93f7ba06e | ||
|
|
4e71e043e7 | ||
|
|
7b416ad0ed | ||
|
|
ece6d49929 | ||
|
|
6dc033d57e | ||
|
|
9663636d10 | ||
|
|
8910b98a86 | ||
|
|
10dcac4100 | ||
|
|
15c1ade3ef | ||
|
|
8871a2806d | ||
|
|
072e4e7836 | ||
|
|
0f7aaabe3c | ||
|
|
f1451baf40 | ||
|
|
589f45eb74 | ||
|
|
fa73750ca0 | ||
|
|
b49a579b33 | ||
|
|
34ec18feff | ||
|
|
44d8ab9575 | ||
|
|
68910bfc18 | ||
|
|
b5262c22d5 | ||
|
|
fb19e3cef8 | ||
|
|
43d775ec8f | ||
|
|
86eb15cfa3 | ||
|
|
0f0cd47018 | ||
|
|
1ad7e8e415 | ||
|
|
5a3e834c42 | ||
|
|
cc64ca3ab8 | ||
|
|
fd2cdcbf08 | ||
|
|
48845981a1 | ||
|
|
5da64f8d78 | ||
|
|
fe0d702e75 | ||
|
|
d3e25b72ad | ||
|
|
40980093e8 | ||
|
|
538db06efc | ||
|
|
eac852fbda | ||
|
|
0bbf150ca3 | ||
|
|
c0f354cb8f | ||
|
|
b3537ad6c5 | ||
|
|
a77e60b402 | ||
|
|
c1f45a3fd2 | ||
|
|
11d205bf61 | ||
|
|
d628a0687d | ||
|
|
02cc9f766f | ||
|
|
a1d051172d | ||
|
|
5b5c635be6 | ||
|
|
ccb8392219 | ||
|
|
d8c20728cb | ||
|
|
0e9e5db486 | ||
|
|
a9516996b4 | ||
|
|
02a046321f | ||
|
|
6a5a8d318e | ||
|
|
a19e5f4500 | ||
|
|
c43bff0488 | ||
|
|
a3a3dbe2df | ||
|
|
6e8696fa85 | ||
|
|
959cfbe179 | ||
|
|
325c90818f | ||
|
|
0f5166e7d1 | ||
|
|
9c1bb35a78 | ||
|
|
a53d5266de | ||
|
|
3065e99c43 | ||
|
|
844d16e677 | ||
|
|
402fdcc453 | ||
|
|
8b99795dae | ||
|
|
7297b2ff7c | ||
|
|
feb760e2a0 | ||
|
|
33316aa494 | ||
|
|
7b2d057f34 | ||
|
|
c66c1f52fa | ||
|
|
e458e8d75a | ||
|
|
f8edcfedd5 | ||
|
|
f58d849500 | ||
|
|
b4f2d6bf5a | ||
|
|
e7e844b7e8 | ||
|
|
f1ab33c025 | ||
|
|
b1fa0ce411 | ||
|
|
0984a7f450 | ||
|
|
02ad0ebcc7 | ||
|
|
7dc9817e71 | ||
|
|
c858bef9c3 | ||
|
|
1ee8fe28ec | ||
|
|
59af070e7d | ||
|
|
223770e18c | ||
|
|
a9bde14434 | ||
|
|
0494e3c1a5 | ||
|
|
e138868abc | ||
|
|
a5f95cf172 | ||
|
|
7845d91bc5 | ||
|
|
50612dcc39 | ||
|
|
9a3b5fafb4 | ||
|
|
292a0a547c | ||
|
|
107d5f4432 | ||
|
|
e709d538af | ||
|
|
9f7ee12218 | ||
|
|
0cb57064b1 | ||
|
|
5ae0ff083f | ||
|
|
4598f68d28 | ||
|
|
d188ead525 | ||
|
|
fa68a364c7 | ||
|
|
97d3036e6a | ||
|
|
eff7a6a8ed | ||
|
|
37ca8bdd52 | ||
|
|
fd254a6aa2 | ||
|
|
c10236108c | ||
|
|
70da7e0faf | ||
|
|
fa0f2ceca1 | ||
|
|
6fe52c065f | ||
|
|
a4d19134b7 | ||
|
|
9071dfba6d | ||
|
|
690169bda3 | ||
|
|
febb9f3e70 | ||
|
|
d6094ed393 | ||
|
|
7bcfcac9ad | ||
|
|
3884ded916 | ||
|
|
9a976137f0 | ||
|
|
e288d1b31d | ||
|
|
498a4ba935 | ||
|
|
236c39ac49 | ||
|
|
4dd6d6c48e | ||
|
|
1bf1e42b49 | ||
|
|
47af97ee11 | ||
|
|
77f00ae975 | ||
|
|
07dcf313df | ||
|
|
f7369ff40e | ||
|
|
770909dec7 | ||
|
|
a75c7371f5 | ||
|
|
e897bd3eba | ||
|
|
43de217109 | ||
|
|
fd25a63bf0 | ||
|
|
1d59df9f73 | ||
|
|
96fc26a1ae | ||
|
|
67de90c37b | ||
|
|
e748064969 | ||
|
|
bb9ec6dfd6 | ||
|
|
e69ceaeb9a | ||
|
|
ec3508a0bf | ||
|
|
ab813bbca4 | ||
|
|
2a1123f549 | ||
|
|
3564938b72 | ||
|
|
0e9b5cfbbf | ||
|
|
098c069d53 | ||
|
|
b27b9f8d7c | ||
|
|
8247d739ce | ||
|
|
599c3b17f6 | ||
|
|
059517f740 | ||
|
|
73a00a3760 | ||
|
|
d826c6ba61 | ||
|
|
83149630ab | ||
|
|
826d86f70d | ||
|
|
764c82ee73 | ||
|
|
a0f9651c6b | ||
|
|
c80605ac4e | ||
|
|
3f14a6ff35 | ||
|
|
02ed686129 | ||
|
|
39022106c3 | ||
|
|
a94cd543d0 | ||
|
|
71e05b1956 | ||
|
|
1ddbe4548f | ||
|
|
b20faa0ca8 | ||
|
|
2eb0e365b7 | ||
|
|
4165b2e9f4 | ||
|
|
be1f765243 | ||
|
|
8c3c9aedc2 | ||
|
|
f68d2ece81 | ||
|
|
dd785c5ca7 | ||
|
|
8ad2d0cec6 | ||
|
|
8000e1b75c | ||
|
|
c6baeb8136 | ||
|
|
62d3768428 | ||
|
|
e78592839f | ||
|
|
33b3600bf8 | ||
|
|
ba455fb098 | ||
|
|
55d9ef19e0 | ||
|
|
0962aad413 | ||
|
|
62f2e4aafb | ||
|
|
51a526eb06 | ||
|
|
619cd5ab1e | ||
|
|
3a9a09aeb9 | ||
|
|
6097590854 | ||
|
|
b9ae37a5f5 | ||
|
|
36942a27ec | ||
|
|
ef6fadcf24 | ||
|
|
63b824e4a1 | ||
|
|
0b36ce892b | ||
|
|
94ccd7f192 | ||
|
|
c661550091 | ||
|
|
bf189b33d5 | ||
|
|
af90203f39 | ||
|
|
435cdb178b | ||
|
|
743f8b08c8 | ||
|
|
d9235c39b1 | ||
|
|
b9df102a80 | ||
|
|
2a24f9ff3a | ||
|
|
d3881c84a1 | ||
|
|
31fe070a45 | ||
|
|
7d267d0be6 | ||
|
|
f922dca356 | ||
|
|
dc05a5d176 | ||
|
|
12082228e7 | ||
|
|
bc7ea336e7 | ||
|
|
6703583db8 | ||
|
|
ace0486f9d | ||
|
|
d41be86fae | ||
|
|
8d7bda2234 | ||
|
|
d1ee97b3ec | ||
|
|
d167a1f564 | ||
|
|
64d1cb157e | ||
|
|
17a2149fb0 | ||
|
|
443db49546 | ||
|
|
8eeb0a8032 | ||
|
|
3ac0afa979 | ||
|
|
e5f54c6ba8 | ||
|
|
c5720e79bd | ||
|
|
4cda98f227 | ||
|
|
5a0e05e6bf | ||
|
|
c77781062a | ||
|
|
ff0f9dc0cd | ||
|
|
b23e29e625 | ||
|
|
54054fb172 | ||
|
|
c2d0ef5379 | ||
|
|
723adde0bb | ||
|
|
3bcafc986c | ||
|
|
a62851256b | ||
|
|
c374863c87 | ||
|
|
33e6bd0d34 | ||
|
|
11ad8ca4bc | ||
|
|
221aa9097b | ||
|
|
39529a669b | ||
|
|
13de622c6b | ||
|
|
0e1803ec12 | ||
|
|
04841aaab9 | ||
|
|
4fabb7685e | ||
|
|
ca54a3a323 | ||
|
|
c13351d21c | ||
|
|
885f7fb98a | ||
|
|
9e34d23129 | ||
|
|
4b491b8fa5 | ||
|
|
0da118e10b | ||
|
|
915e0cce0d | ||
|
|
24e83ea9b1 | ||
|
|
51f6793177 | ||
|
|
8a9ee8c85e | ||
|
|
191f880fa2 | ||
|
|
f8efcc92d7 | ||
|
|
47b153503f | ||
|
|
bbd1a37bd8 | ||
|
|
63a7cd9ae4 | ||
|
|
39c06371e3 | ||
|
|
ede4f4e0f2 | ||
|
|
99374cf76f | ||
|
|
395af63b1a | ||
|
|
87b8c6d837 | ||
|
|
73ca8a3f9a | ||
|
|
ad0f8c2cd8 | ||
|
|
19836dd2d3 | ||
|
|
07fe3ab064 | ||
|
|
320315c0ae | ||
|
|
4758c8402d | ||
|
|
810e33ebf4 | ||
|
|
c46cbbf509 | ||
|
|
97c92b97e0 | ||
|
|
9c7a110d22 | ||
|
|
ea3c628615 | ||
|
|
59c5d705bd | ||
|
|
9c0b11f7da | ||
|
|
7a5c382eb4 | ||
|
|
b85f21a01d | ||
|
|
af314d3abe | ||
|
|
9957702099 | ||
|
|
519abd399a | ||
|
|
bb82b667dd | ||
|
|
c6c42c4d9b | ||
|
|
3d92673078 | ||
|
|
a2cc1bb844 | ||
|
|
738ede31c3 | ||
|
|
b9132af764 | ||
|
|
160b729e15 | ||
|
|
a10a321e3b | ||
|
|
c0cfd134cb | ||
|
|
84cd82faa6 | ||
|
|
6f2b804af4 | ||
|
|
6f51e4a804 | ||
|
|
7d43164d26 | ||
|
|
78d1f07027 | ||
|
|
c6a1030035 | ||
|
|
843a679c25 | ||
|
|
02c83002dc | ||
|
|
efb38ff894 | ||
|
|
9a053e8bdf | ||
|
|
710205768b | ||
|
|
d23a72887b | ||
|
|
eb48d7fc60 | ||
|
|
8eb59674a8 | ||
|
|
8c760f2bef | ||
|
|
04d3795103 | ||
|
|
747a84ac16 | ||
|
|
00b0c735bb | ||
|
|
52966e69d1 | ||
|
|
c03fc070ba | ||
|
|
069527a066 | ||
|
|
a89bf7d085 | ||
|
|
885a2d06ac | ||
|
|
df7eb75e50 | ||
|
|
7cfe29b435 | ||
|
|
b2b75d4cb0 | ||
|
|
2003f38a55 | ||
|
|
f20a9e376f | ||
|
|
0909817018 | ||
|
|
dc839bfb99 | ||
|
|
5a62929f91 | ||
|
|
d39add1a44 | ||
|
|
7f125756a3 | ||
|
|
4abe679525 | ||
|
|
ecdbf6c7f5 | ||
|
|
2b5f2b7af7 | ||
|
|
7e12626248 |
@@ -1,8 +1,12 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Every branch, not just main. The pre-commit hook now scopes its checks to
|
||||
# the files that changed (see .husky/pre-commit), which is only safe because
|
||||
# the full suite runs here on every push — previously a feature branch got no
|
||||
# remote verification at all until a PR was opened.
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Discord announce
|
||||
|
||||
# Two channels, deliberately asymmetric.
|
||||
#
|
||||
# staff — posts AUTOMATICALLY when semantic-release publishes a release.
|
||||
# The post carries a link back to this workflow, so the review and
|
||||
# the publish button are the same artefact.
|
||||
# public — only ever from a manual run. A release can never announce itself
|
||||
# to everyone without someone having read it first.
|
||||
#
|
||||
# Deliberately a SEPARATE workflow from release.yml: a Discord outage, a
|
||||
# rotated webhook or a rate limit must never fail a release that already
|
||||
# shipped. Re-run this job instead.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Where to post."
|
||||
required: true
|
||||
type: choice
|
||||
default: staff
|
||||
options: [staff, public]
|
||||
since:
|
||||
description: "Roll up every release after this tag (e.g. v2.10.2). Blank = latest release only."
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Print the message instead of posting it."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
# setup-node sniffs the lockfile and tries to prime a pnpm cache,
|
||||
# which fails because pnpm is never installed here. It is not needed:
|
||||
# the announce script has no dependencies at all — plain ESM and the
|
||||
# global fetch built into Node 18+. Nothing is installed, so there is
|
||||
# nothing to cache.
|
||||
package-manager-cache: false
|
||||
|
||||
# A `release` event always posts, and always to staff. A dispatch honours
|
||||
# both inputs, with dry_run defaulting to true so a manual run cannot
|
||||
# surprise anyone.
|
||||
- name: Announce
|
||||
env:
|
||||
DISCORD_WEBHOOK_STAFF: ${{ secrets.DISCORD_WEBHOOK_STAFF }}
|
||||
DISCORD_WEBHOOK_PUBLIC: ${{ secrets.DISCORD_WEBHOOK_PUBLIC }}
|
||||
# Kept so an existing single-webhook setup keeps working as staff.
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_ROLE_ID: ${{ secrets.DISCORD_ROLE_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARGS=""
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
ARGS="--version ${{ github.event.release.tag_name }} --channel staff --post"
|
||||
else
|
||||
ARGS="--channel ${{ inputs.channel }}"
|
||||
if [ -n "${{ inputs.since }}" ]; then
|
||||
ARGS="$ARGS --since ${{ inputs.since }}"
|
||||
fi
|
||||
if [ "${{ inputs.dry_run }}" = "false" ]; then
|
||||
ARGS="$ARGS --post"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Running: discord-announce.mjs $ARGS"
|
||||
node scripts/discord-announce.mjs $ARGS
|
||||
@@ -3,6 +3,25 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Tooling-only pushes must not be able to cut a release, whatever their
|
||||
# commit type says.
|
||||
#
|
||||
# 2.13.0 shipped as an empty release because three commits touching only
|
||||
# `scripts/` and `.github/` were typed `feat(announce)` / `fix(announce)`.
|
||||
# Both are release-triggering, so semantic-release did exactly what it was
|
||||
# told — every user on 2.12.0 was offered an update with a blank changelog.
|
||||
# Relying on remembering to type those `ci:` had already failed once.
|
||||
#
|
||||
# Neither path is part of what a user installs: `scripts/` is dev tooling
|
||||
# and `.github/` is CI. GitHub skips the run only when EVERY changed file
|
||||
# matches, so a push that touches shipped code alongside a workflow still
|
||||
# releases correctly.
|
||||
#
|
||||
# `docs/**` is deliberately NOT here — `docs(readme)` is configured to bump
|
||||
# a patch, and `docs/release-notes/next.md` is the release body itself.
|
||||
paths-ignore:
|
||||
- 'scripts/**'
|
||||
- '.github/**'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
+14
@@ -12,6 +12,12 @@ node_modules/
|
||||
pnpm-debug.log
|
||||
.pnpm-store/
|
||||
|
||||
# Vite per-contributor env overrides (e.g. VITE_WPC_API_URL pointing
|
||||
# at a dev community host). Never commit — leaks dev URLs into the
|
||||
# tree and makes `pnpm build` produce divergent bundles per checkout.
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build outputs
|
||||
js/
|
||||
web/
|
||||
@@ -46,9 +52,17 @@ Thumbs.db
|
||||
/docs/*
|
||||
!/docs/help/
|
||||
!/docs/import-export-contract.md
|
||||
# Curated release highlights — read by .releaserc.js at release time, so it
|
||||
# must be tracked (the release step commits the cleared file back).
|
||||
!/docs/release-notes/
|
||||
|
||||
# Semantic release
|
||||
.releaserc.local.js
|
||||
|
||||
# Throwaway design / scratch artifacts (mockups, screenshots, demos)
|
||||
/_temp/
|
||||
|
||||
# DB location control — per-contributor sidecar config + root-location DB dir
|
||||
db-config.json
|
||||
/db/
|
||||
.tmp-check/
|
||||
|
||||
+64
-14
@@ -1,18 +1,68 @@
|
||||
# Lint + typecheck + frontend tests
|
||||
pnpm lint || exit 1
|
||||
pnpm typecheck || exit 1
|
||||
pnpm test || exit 1
|
||||
# Pre-commit gate, scoped to what actually changed.
|
||||
#
|
||||
# This used to run the whole CI suite on every commit — lint, typecheck, the
|
||||
# full 4000-test vitest run, a build, the size gate, ruff and the full pytest
|
||||
# suite — about 4.5 minutes regardless of whether the commit touched one Vue
|
||||
# file or one markdown file. CI (.github/workflows/ci.yml) now runs on every
|
||||
# push, not just main, so the local hook's job is to catch mistakes fast rather
|
||||
# than re-prove the whole tree.
|
||||
#
|
||||
# Measured on this machine:
|
||||
# lint 11s · typecheck 18s · full vitest 150s · build 10s · size 2s
|
||||
# ruff 0s · full pytest 76s · vitest scoped to changed files ~30s
|
||||
#
|
||||
# So a frontend-only commit drops from ~4.5min to ~70s, a Python-only commit
|
||||
# skips the JS half, and a docs-only commit exits immediately.
|
||||
#
|
||||
# Escape hatch: `WP_FULL=1 git commit …` runs everything, unscoped.
|
||||
|
||||
# Bundle size budget — build the extension chunk and run the size check so
|
||||
# regressions get caught locally before the CI flags them on PR. Skips when
|
||||
# js/ output already matches the source by mtime to keep repeat commits fast.
|
||||
pnpm build:extension || exit 1
|
||||
pnpm size || exit 1
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
[ -z "$staged" ] && exit 0
|
||||
|
||||
# Python checks — only if ruff/pytest reachable on PATH
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
ruff check . || exit 1
|
||||
has() { echo "$staged" | grep -qE "$1"; }
|
||||
|
||||
FRONT=0
|
||||
PY=0
|
||||
# Anything that can change the bundle or its types.
|
||||
has '^(src/|scripts/|package\.json|pnpm-lock\.yaml|vite\.|tsconfig|eslint)' && FRONT=1
|
||||
# Anything the Python suites cover.
|
||||
has '(\.py$|^(engine/|wp_nodes/|wp_api/|tests/|pyproject\.toml))' && PY=1
|
||||
|
||||
if [ "$WP_FULL" = "1" ]; then
|
||||
FRONT=1
|
||||
PY=1
|
||||
fi
|
||||
if command -v pytest >/dev/null 2>&1; then
|
||||
pytest -q || exit 1
|
||||
|
||||
if [ "$FRONT" = "1" ]; then
|
||||
pnpm lint || exit 1
|
||||
pnpm typecheck || exit 1
|
||||
|
||||
# Only the tests reachable from the staged sources. `vitest related` walks
|
||||
# the import graph, so a change to a shared module still pulls in every
|
||||
# dependent suite — narrower than the full run, not weaker for the code
|
||||
# being committed. CI still runs the whole suite with coverage.
|
||||
if [ "$WP_FULL" = "1" ]; then
|
||||
pnpm test || exit 1
|
||||
else
|
||||
changed_src=$(echo "$staged" | grep -E '^src/.*\.(ts|vue)$' | tr '\n' ' ')
|
||||
if [ -n "$changed_src" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
pnpm exec vitest related --run $changed_src || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Bundle budget — cheap, and a regression here stays invisible until release.
|
||||
pnpm build:extension || exit 1
|
||||
pnpm size || exit 1
|
||||
fi
|
||||
|
||||
if [ "$PY" = "1" ]; then
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
ruff check . || exit 1
|
||||
fi
|
||||
if command -v pytest >/dev/null 2>&1; then
|
||||
pytest -q || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
+38
-14
@@ -18,6 +18,21 @@
|
||||
* notes shrink to just the meaningful changes (typically <50 entries
|
||||
* per release).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
|
||||
// Curated, human-readable highlights for the NEXT release. A maintainer
|
||||
// writes plain-language "what changed for you" bullets here before merging a
|
||||
// release; if the file is empty/absent the release body simply falls back to
|
||||
// the auto-generated grouped changelog. The file is read at release time (in
|
||||
// CI) and cleared by the prepare step below so highlights never repeat.
|
||||
const HIGHLIGHTS_FILE = "docs/release-notes/next.md";
|
||||
let highlights = "";
|
||||
try {
|
||||
highlights = fs.readFileSync(HIGHLIGHTS_FILE, "utf8").trim();
|
||||
} catch {
|
||||
/* no curated highlights for this release */
|
||||
}
|
||||
|
||||
export default {
|
||||
branches: ["main"],
|
||||
plugins: [
|
||||
@@ -69,24 +84,30 @@ export default {
|
||||
],
|
||||
},
|
||||
writerOpts: {
|
||||
// headerPartial puts a fixed "TL;DR + useful links" block at
|
||||
// the very top of every release body, so the first ~600
|
||||
// chars GitHub renders above the "Read more" fold on the
|
||||
// releases list view stay useful no matter how long the
|
||||
// changelog gets. {{version}} expands to the new tag.
|
||||
// The body IS the curated notes. See docs/release-notes/next.md for
|
||||
// the structure and the rules that keep it short.
|
||||
//
|
||||
// The per-commit list used to be inlined here inside a <details>.
|
||||
// It was dropped: with the hidden-type filter already applied it
|
||||
// said the same things as the curated notes, only worse, and it grew
|
||||
// linearly with the release (81 entries for this one). A compare
|
||||
// link is strictly more useful -- diffable, searchable, one line.
|
||||
headerPartial:
|
||||
"## 🎉 Wildcard Pipeline {{version}}\n\n" +
|
||||
"📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · " +
|
||||
"💬 [Discord](https://discord.gg/BFYR9WQdVR) · " +
|
||||
"📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · " +
|
||||
"🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)\n\n" +
|
||||
"<details>\n" +
|
||||
"<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>\n\n",
|
||||
// footerPartial closes the <details> wrapper so the wall of
|
||||
// commits stays collapsed by default. GitHub renders
|
||||
// <details> natively in markdown — readers click to expand
|
||||
// when they actually need the per-commit log.
|
||||
footerPartial: "\n</details>",
|
||||
(highlights ? `${highlights}\n\n` : ""),
|
||||
// Compare link instead of the commit dump.
|
||||
footerPartial:
|
||||
"\n---\n\n**Full changelog:** " +
|
||||
"[{{previousTag}}…{{currentTag}}]" +
|
||||
"(https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/{{previousTag}}...{{currentTag}})\n",
|
||||
// Render no commit sections. The curated notes above ARE the release
|
||||
// notes; commits still drive the version bump (see commit-analyzer)
|
||||
// and still live in git -- they are simply not restated here.
|
||||
transform: () => false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -102,14 +123,17 @@ export default {
|
||||
// @semantic-release/github plugin can attach it to the
|
||||
// release. Zip is built AFTER versioning so the file lands
|
||||
// with the right version in its name.
|
||||
// 3. Clear the consumed highlights file so the next release starts
|
||||
// from a clean slate (the content already lives in this release's
|
||||
// notes + CHANGELOG.md).
|
||||
prepareCmd:
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node scripts/pack-release.mjs ${nextRelease.version}",
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node -e \"require('fs').writeFileSync('docs/release-notes/next.md','')\" && node scripts/pack-release.mjs ${nextRelease.version}",
|
||||
},
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
assets: ["CHANGELOG.md", "package.json", "pyproject.toml"],
|
||||
assets: ["CHANGELOG.md", "package.json", "pyproject.toml", "docs/release-notes/next.md"],
|
||||
// Plain message — `${nextRelease.notes}` here would expand to
|
||||
// the entire changelog, which on a divergent history (where
|
||||
// semantic-release can't find the last tag in main's first-
|
||||
|
||||
+905
@@ -1,3 +1,908 @@
|
||||
## 🎉 Wildcard Pipeline 2.13.2
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### Firefox: the editor no longer dies when you insert a nested reference
|
||||
|
||||
Picking a wildcard from the `@` autocomplete could take down the whole editor —
|
||||
the field stopped accepting text, Backspace did nothing, and Save and Cancel
|
||||
stopped responding until the page was reloaded. Select-all then cut inside an
|
||||
option value did the same thing.
|
||||
|
||||
Both were the same cause. Firefox removes the empty text nodes that Vue uses to
|
||||
mark where each chip and text run begins and ends, and once they are gone any
|
||||
edit that adds or removes a chip runs off the end of the field's contents.
|
||||
Chromium keeps those markers, which is why this only ever happened on Firefox
|
||||
and never reproduced during development.
|
||||
|
||||
The editor now notices when those markers have been removed and rebuilds the
|
||||
field instead of tripping over them. This affects every rich text field —
|
||||
option values, combine templates, derivation rules and injector bindings, on
|
||||
the canvas and in the manager alike.
|
||||
|
||||
### Your library is protected against version mismatches
|
||||
|
||||
If a database has been through migrations that this build does not recognise —
|
||||
after downgrading, hand-installing an older copy, or switching versions in
|
||||
ComfyUI Manager — Wildcard Pipeline now stops with a clear message instead of
|
||||
quietly reading a library it does not understand. Previously it carried on, and
|
||||
every later edit was written through assumptions that no longer held.
|
||||
|
||||
### Release archives now include the interface translations
|
||||
|
||||
The release zip was missing `locales/`, so node names, descriptions and setting
|
||||
labels fell back to raw identifiers for anyone installing from it. Installs
|
||||
from ComfyUI Manager and the Comfy Registry were unaffected.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.13.1…v2.13.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.1...v2.13.2)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.13.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
A security fix for the database location setting. Update when you can.
|
||||
|
||||
> **Already on 2.13.0?** This is the same release. 2.13.0 reached GitHub but not the
|
||||
> ComfyUI Registry, so it never arrived for anyone installing through ComfyUI Manager.
|
||||
> 2.13.1 is that same build under a version number the registry will accept — nothing
|
||||
> in it differs.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
|
||||
|
||||
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
|
||||
|
||||
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
|
||||
|
||||
<!--
|
||||
HOW TO WRITE THIS FILE
|
||||
|
||||
Order, and nothing else:
|
||||
|
||||
Headline one sentence, what this release is about
|
||||
### Highlights 4-5 max
|
||||
<!- /modal -> the update dialog stops rendering here
|
||||
<details> Also in this… grouped one-liners, collapsed
|
||||
### Upgrade notes only when the reader must act; delete otherwise
|
||||
— keep this OUTSIDE the <details>, it needs seeing
|
||||
|
||||
This file IS the release body — the compare link is appended automatically and
|
||||
the per-commit list is deliberately not included. It is consumed at release
|
||||
time and cleared afterwards, so anything left here ships.
|
||||
|
||||
RULES
|
||||
|
||||
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
|
||||
6-commit release and an 80-commit release roughly the same height. If a
|
||||
sixth feels essential, something else was not a highlight.
|
||||
|
||||
2. A highlight passes one of three tests:
|
||||
- new capability — you can do something you could not before
|
||||
- wall removed — something that blocked you now works
|
||||
- surprising — behaviour changed in a way you should not meet alone
|
||||
Polish, truncation, layout and invisible perf fail all three.
|
||||
|
||||
3. Commit count is NOT a test. The categories icon picker was two commits and a
|
||||
real feature; `ui` was nine commits and almost entirely polish.
|
||||
|
||||
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
|
||||
names are free-form — use whatever the release actually contains. It sits in
|
||||
a <details> so a long release does not double the page; put the count in the
|
||||
summary so the reader knows what they are opening. That number is written by
|
||||
hand here and can drift — the update dialog computes its own from the same
|
||||
bullets, so if the two disagree, this one is wrong. Note also that collapsed
|
||||
text is not reliably reachable by browser find-in-page, which is the cost.
|
||||
|
||||
5. Write what changed for the user, not what changed in the code. Name the
|
||||
symptom, not the fix: "the field went blank" beats "guarded a null deref".
|
||||
|
||||
6. Perf belongs in the tail WITH its numbers, so the people it affects
|
||||
recognise themselves. "Performance improvements" tells nobody anything.
|
||||
|
||||
7. Security fixes say what an attacker could have done, in plain language, and
|
||||
whether the reader must act. Never ship one as "hardening" or "internal
|
||||
improvements" — someone deciding whether to update tonight needs the facts,
|
||||
and vagueness reads as a cover-up once the diff is public anyway.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.13.0…v2.13.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.0...v2.13.1)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.13.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
A security fix for the database location setting. Update when you can.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
|
||||
|
||||
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
|
||||
|
||||
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
|
||||
|
||||
<!--
|
||||
HOW TO WRITE THIS FILE
|
||||
|
||||
Order, and nothing else:
|
||||
|
||||
Headline one sentence, what this release is about
|
||||
### Highlights 4-5 max
|
||||
<!- /modal -> the update dialog stops rendering here
|
||||
<details> Also in this… grouped one-liners, collapsed
|
||||
### Upgrade notes only when the reader must act; delete otherwise
|
||||
— keep this OUTSIDE the <details>, it needs seeing
|
||||
|
||||
This file IS the release body — the compare link is appended automatically and
|
||||
the per-commit list is deliberately not included. It is consumed at release
|
||||
time and cleared afterwards, so anything left here ships.
|
||||
|
||||
RULES
|
||||
|
||||
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
|
||||
6-commit release and an 80-commit release roughly the same height. If a
|
||||
sixth feels essential, something else was not a highlight.
|
||||
|
||||
2. A highlight passes one of three tests:
|
||||
- new capability — you can do something you could not before
|
||||
- wall removed — something that blocked you now works
|
||||
- surprising — behaviour changed in a way you should not meet alone
|
||||
Polish, truncation, layout and invisible perf fail all three.
|
||||
|
||||
3. Commit count is NOT a test. The categories icon picker was two commits and a
|
||||
real feature; `ui` was nine commits and almost entirely polish.
|
||||
|
||||
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
|
||||
names are free-form — use whatever the release actually contains. It sits in
|
||||
a <details> so a long release does not double the page; put the count in the
|
||||
summary so the reader knows what they are opening. That number is written by
|
||||
hand here and can drift — the update dialog computes its own from the same
|
||||
bullets, so if the two disagree, this one is wrong. Note also that collapsed
|
||||
text is not reliably reachable by browser find-in-page, which is the cost.
|
||||
|
||||
5. Write what changed for the user, not what changed in the code. Name the
|
||||
symptom, not the fix: "the field went blank" beats "guarded a null deref".
|
||||
|
||||
6. Perf belongs in the tail WITH its numbers, so the people it affects
|
||||
recognise themselves. "Performance improvements" tells nobody anything.
|
||||
|
||||
7. Security fixes say what an attacker could have done, in plain language, and
|
||||
whether the reader must act. Never ship one as "hardening" or "internal
|
||||
improvements" — someone deciding whether to update tonight needs the facts,
|
||||
and vagueness reads as a cover-up once the diff is public anyway.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.12.0…v2.13.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.12.0...v2.13.0)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.12.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
Full support for ComfyUI's Nodes 2.0 renderer, and a pass over everything to do with knowing where a value came from.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Every node works under Nodes 2.0.** ComfyUI's new Vue renderer changed how nodes are laid out, sized and scrolled, and our widgets were built against the old one. Nodes now render, resize, scroll and merge their wires correctly under both renderers — including switching between them with a workflow already open.
|
||||
- **See where a variable or reference comes from.** Hover any `$var` or `@{ref}` chip and the card names the module that writes it, the node it lives in, and — for references — the pool it resolves against and how many options currently match. Variables produced by a Context Injector, by a sibling module, or by another node are all attributed instead of reading "binds at runtime".
|
||||
- **The `@` and `$` pickers were rebuilt.** Rows carry an identity so two similarly-named entries are tellable apart, pools supplied by a node are marked as such, and the sub-category filter panel is redesigned — chips reflect the parsed expression, and exception values get a picker of real option values instead of a free-text box.
|
||||
- **Wildcard options and fixed values became editable at scale.** A pinned search bar, tag filtering, drag-to-reorder, and bulk edit that can move rows to top or bottom and add or remove sub-categories across a selection.
|
||||
- **Text editing in options no longer breaks.** Cut, paste and select-all could leave a field stuck, mangled, or typing into the wrong place, and a selection would skip over chips. Keystrokes also no longer escape a modal into ComfyUI's global shortcuts — pressing a plain letter could trigger a node-definition refresh and freeze the page.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
<details>
|
||||
<summary><b>Also in this release</b> — 26 smaller changes</summary>
|
||||
|
||||
**Sequences and seeds**
|
||||
|
||||
- Lock a frame's seed or bypass it straight from its chip on the Context Loop — shift-click to lock, alt-click to bypass — instead of opening the per-iteration seeds dialog
|
||||
- The Seed List gets the same frame chips; clicking one locks its seed, and frames bypassed by a wired Context Loop are shown but not editable there, since that setting belongs to the loop
|
||||
- The frame grid collapses, and scrolls once it outgrows four rows, so a hundred-frame loop no longer owns the canvas. Collapsed state is saved with the workflow, and the header keeps reporting how many frames are locked or bypassed
|
||||
|
||||
**Library and organisation**
|
||||
|
||||
- Categories get an icon picker, and the icon follows the category everywhere it appears
|
||||
- Export and import pickers gain cross-bucket search and per-row detail (`$variable`, option counts)
|
||||
- Copy payload emits an importable envelope, stamped with its schema version
|
||||
- Re-link matches bundles on child content rather than only a stored hash, and offers itself for modules that have no hash at all
|
||||
- An unlinked bundle now reads as missing rather than modified, and reset-to-snapshot refuses to empty one
|
||||
|
||||
**Chips and references**
|
||||
|
||||
- Nested `@{uuid}` references are discovered even when written outside a wildcard payload
|
||||
- Reference hovers name the pool they counted and flag drift against the library
|
||||
- Wildcard option chips get the same hover card as every other surface
|
||||
- Sub-category filter state is derived from the parsed expression, so the chips match what the filter actually does
|
||||
- A reference to a module you have deleted from the library now reads as broken. It used to keep the deleted module's name and colour, and its hover card quoted the option count the module had before it was deleted — the node badge said the reference was broken while the chip inside the editor disagreed
|
||||
|
||||
**The manager**
|
||||
|
||||
- **What's new** moved to its own page, reached from the topbar with a dot when there is something unread. It is also the only place to read the notes for the version you are running — the update pill only exists while a newer one is available
|
||||
- The update check no longer burns through GitHub's hourly allowance. It was making one request per page load per consumer, so a few dozen reloads could exhaust the quota for everything else on that connection; results are now reused for six hours and a refusal is honoured rather than retried
|
||||
|
||||
**Editing and layout**
|
||||
|
||||
- Fixed-value and combine fields grow with their content, with a corner grip that stays reachable and a manual drag that overrides the auto height
|
||||
- A scrollable field no longer hands its scroll to the page behind it
|
||||
- Rule previews truncate with an ellipsis instead of being clipped or faded, and long values wrap
|
||||
- Tag menus stay inside the viewport, keep their own scroll, and fold by axis
|
||||
- Dropdowns say "type to filter", which they have always supported
|
||||
- The save shortcut reads `Ctrl` on Windows and Linux instead of a Mac-only glyph
|
||||
- Test-runner histogram rows are banded so a row reads across its columns, and its module picker survives duplicate names
|
||||
|
||||
**Performance and correctness**
|
||||
|
||||
- Canvas polling is roughly eight times cheaper on a large graph — the walkers now share one parse instead of re-parsing a 294 KB widget value ten times per node every cycle (~21% of a core down to ~2.6%)
|
||||
- Wildcard snapshots refresh when the window regains focus instead of waiting out a timer, and expire so the canvas stops drifting from the library
|
||||
- The Prompt Cleaner no longer rewrites `embedding:name` or `<lora:name:1.0>` references — punctuation stripping renamed embeddings ending in `_`, and fuzzy dedupe dropped one of two versioned files
|
||||
- Variable override counts report writers rather than write statements, so a module that creates a variable is no longer described as overriding writes that never happened
|
||||
|
||||
</details>
|
||||
|
||||
<!--
|
||||
HOW TO WRITE THIS FILE
|
||||
|
||||
Order, and nothing else:
|
||||
|
||||
Headline one sentence, what this release is about
|
||||
### Highlights 4-5 max
|
||||
<!- /modal -> the update dialog stops rendering here
|
||||
<details> Also in this… grouped one-liners, collapsed
|
||||
### Upgrade notes only when the reader must act; delete otherwise
|
||||
— keep this OUTSIDE the <details>, it needs seeing
|
||||
|
||||
This file IS the release body — the compare link is appended automatically and
|
||||
the per-commit list is deliberately not included. It is consumed at release
|
||||
time and cleared afterwards, so anything left here ships.
|
||||
|
||||
RULES
|
||||
|
||||
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
|
||||
6-commit release and an 80-commit release roughly the same height. If a
|
||||
sixth feels essential, something else was not a highlight.
|
||||
|
||||
2. A highlight passes one of three tests:
|
||||
- new capability — you can do something you could not before
|
||||
- wall removed — something that blocked you now works
|
||||
- surprising — behaviour changed in a way you should not meet alone
|
||||
Polish, truncation, layout and invisible perf fail all three.
|
||||
|
||||
3. Commit count is NOT a test. The categories icon picker was two commits and a
|
||||
real feature; `ui` was nine commits and almost entirely polish.
|
||||
|
||||
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
|
||||
names are free-form — use whatever the release actually contains. It sits in
|
||||
a <details> so a long release does not double the page; put the count in the
|
||||
summary so the reader knows what they are opening. That number is written by
|
||||
hand here and can drift — the update dialog computes its own from the same
|
||||
bullets, so if the two disagree, this one is wrong. Note also that collapsed
|
||||
text is not reliably reachable by browser find-in-page, which is the cost.
|
||||
|
||||
5. Write what changed for the user, not what changed in the code. Name the
|
||||
symptom, not the fix: "the field went blank" beats "guarded a null deref".
|
||||
|
||||
6. Perf belongs in the tail WITH its numbers, so the people it affects
|
||||
recognise themselves. "Performance improvements" tells nobody anything.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.11.0…v2.12.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.11.0...v2.12.0)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.11.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### ✨ What's new
|
||||
|
||||
A correctness fix for held modules on multi-frame runs, plus a set of tools for reconnecting workflow items to your library.
|
||||
|
||||
- **Held modules stay held for the whole run.** On a multi-frame sequence, a module set to **Hold across run** could quietly re-roll after the first frame — a held outfit or location would change mid-sequence — and every frame after the first filled the debug panel with constraint warnings. Held picks now carry through every frame, so constraints that depend on them keep applying, cross-node holds stay put, and the warning flood is gone.
|
||||
- **Re-link items that lost their library connection.** When a re-import gives a library entry a new id, the copy in your workflow stops matching it: no drift, no **Edit library entry in SPA**, and saving creates a duplicate. **Push to library** now offers **Re-link to an existing library entry** when it finds one holding the same content — picking it automatically when there's exactly one match, or letting you choose from a list you can search by name *or* id. Works for bundles too.
|
||||
- **Disconnected items now show as drifted** instead of looking like local-only rows. That means the **refresh drifted** button reconnects them in one click, and the library link, drift dot and refresh action all start working again.
|
||||
- **"Reset to library snapshot" can no longer empty a bundle.** If the bundle wasn't linked to a library entry, or the snapshot came back empty, reset used to replace your modules with nothing. It now refuses and explains why, leaving the bundle untouched.
|
||||
- **Importing content you already have offers to link it.** When an import contains an item identical to one already in your library under a different id, it's flagged **IN LIBRARY** with the matching entry named, and you can either link to it — references follow to the existing entry — or import the copy anyway. Nothing is linked or overwritten without you choosing.
|
||||
- **Every edit modal shows the item's id.** A small chip in the header, click to copy — the same id that `@{...}` references, constraint sources and library rows all match on.
|
||||
- **Nested references and variables render as chips** in derivation rule values and constraint exceptions, instead of showing raw text inside `{a|b}` braces. Exception values also get a proper picker of real option values rather than a free-text box.
|
||||
- **Hover any reference chip** to see what it resolves to: its id, its sub-category filter in plain language, and how many options currently match — read live from your library, so the count moves when the library does.
|
||||
- **Search by id.** Pasting an 8-character id into a search box now finds the item, in the manager lists and in the remap / reattach pickers. Previously only names matched, which was useless when two entries shared one.
|
||||
- **Long values wrap** in the derivation override boxes instead of being clipped at the edge of the field.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #11 from DumiFlex/fix/held-constraint-registration ([fd4c501](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd4c501)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
|
||||
* docs(release-notes): summarise held-module fix + library re-link tooling ([9fb653f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9fb653f))
|
||||
* chore(size): raise total bundle budget to 461 KB ([3f45b26](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f45b26))
|
||||
* fix(bundle): never let "Reset to library snapshot" silently empty a bundle ([78297c4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/78297c4))
|
||||
* fix(chips): render nested refs + vars in derivation and exception surfaces ([a64e56d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a64e56d)), closes [label/#option](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/option)
|
||||
* fix(engine): carry held modules' frame-0 constraint state to later frames ([1b32a8a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b32a8a))
|
||||
* fix(linkage): surface drift for a library-uuid module with an empty local hash ([bb5f6b8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5f6b8))
|
||||
* fix(search): match modules, bundles and templates by id as well as name ([44d0ca2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d0ca2))
|
||||
* feat(chips): hover card on nested-ref and variable chips ([699deda](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/699deda))
|
||||
* feat(editors): show module identity in edit-modal headers + wrap long values ([5a83e61](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a83e61))
|
||||
* feat(import): link-to-existing decision for content duplicates (D3b core) ([e63be54](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e63be54)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
|
||||
* feat(import): surface content duplicates as a resolvable conflict row (D3b) ([c268e75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c268e75))
|
||||
* feat(relink): content-aware library re-link for detached bundle instances ([ecebd55](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ecebd55))
|
||||
* feat(relink): content-aware library re-link for detached module rows ([a059b48](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a059b48))
|
||||
* feat(relink): content-aware re-link match + apply helpers (pure) ([9ef1fb6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9ef1fb6))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.10.2
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### ✨ What's new
|
||||
|
||||
A bug-fix release for the in-app updater and name validation.
|
||||
|
||||
- **Fixed an updater downgrade.** On 2.10.0, clicking **Update Now** could install an *older* version — it asked ComfyUI Manager for "latest" against a stale cache. The updater now installs the exact new version and refuses to ever move backwards.
|
||||
- **The update badge appears right away** after **Check now** — no page refresh needed.
|
||||
- **Cleaner release notes** in the update dialog (proper formatting instead of raw `<details>` tags).
|
||||
- **Names are validated as you rename them.** Renaming a sub-category or a variable now enforces the same rules as creating one, so invalid names (spaces, reserved characters) are caught immediately instead of failing later on save.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #10 from DumiFlex/fix/updater-downgrade ([a2e93fe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2e93fe)), closes [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
|
||||
* fix(manager): prevent updater downgrade, fix pill reactivity + release notes ([1e42f9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1e42f9b))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.10.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #9 from DumiFlex/fix/subcat-rename-validation ([74c7331](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/74c7331)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
|
||||
* fix(manager): validate names on rename via shared validators (#7) ([00b735d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b735d)), closes [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.10.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #8 from DumiFlex/feat/manager-qol ([fe2423f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fe2423f)), closes [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8)
|
||||
* feat(loop): add optional bypass toggle to seed-lock row ([d524d14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d524d14))
|
||||
* feat(loop): dashed border on bypassed edit-frame chips ([70283ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70283ce))
|
||||
* feat(loop): emit only non-bypassed frames with pinned identity ([9d372b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d372b1))
|
||||
* feat(loop): mirror bypass_frames in the ts config type ([da2239d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/da2239d))
|
||||
* feat(loop): mirror the loop's bypassed frames in the seed list UI ([f670c3d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f670c3d))
|
||||
* feat(loop): opt-in bypass column in the per-iteration modal ([651ca65](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/651ca65))
|
||||
* feat(loop): parse bypass_frames in the loop config ([7aab09d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7aab09d))
|
||||
* feat(loop): purple wash + dashed bar on bypassed seed rows ([ea4ed05](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea4ed05))
|
||||
* feat(loop): wire per-frame bypass through the loop widget ([7545ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7545ade))
|
||||
* feat(manager): add checkOnLaunch update preference to uiStore ([d42b7ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d42b7ca))
|
||||
* feat(manager): add ComfyUI Manager update composable ([327e7cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/327e7cc))
|
||||
* feat(manager): add safe markdown renderer for release notes ([830776e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/830776e))
|
||||
* feat(manager): add UpdateDialog with changelog + Manager-driven update ([290e7a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/290e7a8))
|
||||
* feat(manager): add Updates settings card with check-on-launch + check now ([6c20f53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6c20f53))
|
||||
* feat(manager): bulk select + delete for exceptions + derivation rules (#11) ([ed10b88](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ed10b88)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
|
||||
* feat(manager): bulk select + delete for fixed-values (#11) ([cbf102b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cbf102b)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
|
||||
* feat(manager): drag-and-drop file import (#5) ([d4e6d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d4e6d0c)), closes [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5)
|
||||
* feat(manager): move Target reach above the constraint rule matrix (#10a) ([d039364](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d039364)), closes [#10a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10a)
|
||||
* feat(manager): open UpdateDialog from the topbar update pill ([936a139](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/936a139))
|
||||
* feat(manager): per-branch collapse + collapse-all for derivation rules (#9) ([8ea1571](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ea1571)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9)
|
||||
* feat(manager): per-kind quick-select presets + import-tab mirror (#1) ([8912937](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8912937)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
|
||||
* feat(manager): per-rule branch collapse + collapse rules/branches by default ([fcde3b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fcde3b3))
|
||||
* feat(manager): relevant + colour-coded bulk sub-category menus ([be70a5f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be70a5f)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* feat(manager): rework release check to launch-once + manual, expose release notes ([bdcf85c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bdcf85c))
|
||||
* feat(manager): sticky list headers across editors ([6802f11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6802f11))
|
||||
* fix(loop): parse parity + edge coverage from final review ([bab87cd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bab87cd))
|
||||
* fix(loop): seed list mirrors the loop's bypassed frames ([af95f08](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af95f08))
|
||||
* fix(manager): count templates in the import loaded-bar total ([2295ce0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2295ce0))
|
||||
* fix(manager): detect bundle collisions on import instead of blind-adding (#6) ([b54302d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b54302d)), closes [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
|
||||
* fix(manager): fresh dates for imported adds (#12) + live counts after import (#7) ([696711d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/696711d)), closes [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
|
||||
* fix(manager): keep bulk-edit toolbar stuck with sticky card header ([6f69304](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f69304))
|
||||
* fix(manager): resolve inline multi-pick {N-M$$sep$$…} in the test runner ([8dcef0f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcef0f))
|
||||
* fix(manager): sticky-header subpixel bleed + confusing weight validation ([0bd4382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bd4382))
|
||||
* test(engine): un-skip pipeline trace tests after handler migration ([7d2bd63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d2bd63))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.9.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(derivation-modal): absolute max-height for the rules scroll (revert flex) ([5d715fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5d715fb))
|
||||
* fix(derivation-modal): pin rule cards so the rule list actually scrolls ([fc9d8a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fc9d8a8))
|
||||
* fix(derivation-modal): scroll the rule LIST, not the whole modal ([6f664ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f664ce))
|
||||
* fix(derivation-modal): scroll the rules region, pin header + footer ([e98aca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e98aca3))
|
||||
* fix(engine): derivation hold freezes each branch roll, not the branch choice ([df76abd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df76abd))
|
||||
* fix(engine): hold the resolved VALUE across iterations, not just the seed ([58c5b27](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/58c5b27))
|
||||
* fix(injector): keep custom pin labels with the socket on drag-reorder ([41fae1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/41fae1e))
|
||||
* fix(injector): persist custom pin labels durably across collapse + reload ([8568aa2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8568aa2))
|
||||
* fix(injector): reconstruct collapsed-wire labels from data, not the dead stash ([0ede205](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0ede205))
|
||||
* fix(injector): repair collapse-wire labels corrupted across a reload ([0281b8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0281b8e))
|
||||
* fix(injector): right-align the type pill on label-less ref/slot rows ([bf61f64](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf61f64))
|
||||
* docs(spa): describe hold as a value freeze (constrained picks + nested refs) ([1b3e4aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b3e4aa))
|
||||
* docs(spa): document per-iteration overrides, seed scope + lock-previous ([4a34c9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4a34c9b))
|
||||
* docs(spa): mention the right-click Hold toggle shortcut ([a738618](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a738618))
|
||||
* docs(spa): note derivation supports Hold across run ([aba4e8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/aba4e8c))
|
||||
* feat(context): quick Hold toggle in the module row context menu ([8dcebdc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcebdc))
|
||||
* feat(engine): derivation honors seed_scope=hold (freeze frame-0 output) ([ff6825c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff6825c))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.8.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(context): add hover states to the per-kind edit modal footer buttons ([b292699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b292699))
|
||||
* fix(context): capture the loop's per-frame seeds so the lock pins the previous roll ([b33afd8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b33afd8))
|
||||
* fix(context): clear redundant frame lock-null override when the base unlocks ([f38a316](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f38a316))
|
||||
* fix(context): coerce disabled_frames to int set and guard frame toggle ([65683ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/65683ae))
|
||||
* fix(context): modal lock button pins the rolled seed via injected resolver ([e56fd22](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e56fd22))
|
||||
* fix(context): name the disabled toggles in the per-frame runtime hint + wrap loop frame chips ([2fb853f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2fb853f))
|
||||
* fix(context): per-frame enable overrides base enabled (frame_enabled) ([6eb4b73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6eb4b73))
|
||||
* fix(context): per-frame seed lock captures that frame's own rolled seed ([25388b4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/25388b4))
|
||||
* fix(context): per-frame seed lock computes the frame's real seed from the loop ([1f164df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1f164df))
|
||||
* fix(context): restrict per-frame overrides to an allowlist of meaningful fields ([bb5e84d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5e84d))
|
||||
* fix(context): resync editor draft on frame revert + restyle the banner for readability ([39fdb6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39fdb6f))
|
||||
* fix(context): scope the wrapping grid to the frame chips only ([c48aafb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c48aafb))
|
||||
* fix(context): stack the frame banner above the editor card instead of beside it ([3c492f1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3c492f1))
|
||||
* fix(context): sweep redundant frame lock-null when base unlocks via the modal ([e1885a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e1885a3))
|
||||
* fix(engine): apply iteration_overrides for generic-object modules ([942834d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/942834d))
|
||||
* fix(engine): include disabled_frames in the generic-object snapshot keys ([03796c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/03796c5))
|
||||
* fix(engine): stop double-shifting the per-iteration override seed ([3527ada](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3527ada))
|
||||
* fix(extension): read loop seed and count as numbers for per-frame seed lock ([67a09df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67a09df))
|
||||
* feat(context-loop): add edit-frame selector chip row ([ece6d49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ece6d49))
|
||||
* feat(context-loop): add session frame-cursor singleton ([6dc033d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6dc033d))
|
||||
* feat(context): add hold-across-run toggle to the runtime sections ([9663636](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9663636))
|
||||
* feat(context): add lock-previous-seed to the iteration-seed modals ([c7c867e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c7c867e))
|
||||
* feat(context): add pure frame-override helpers ([7b416ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b416ad))
|
||||
* feat(context): disable run-level controls while editing a frame ([1822297](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1822297))
|
||||
* feat(context): frame banner with revert-to-base in the module editor ([79cf385](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79cf385))
|
||||
* feat(context): held and per-frame override row badges ([e93f7ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e93f7ba))
|
||||
* feat(context): make the row quick-lock act on the active frame ([21f2850](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/21f2850))
|
||||
* feat(context): per-frame enable/disable via disabled_frames ([8a9dc9e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9dc9e))
|
||||
* feat(context): redirect edits into iteration_overrides when a frame is active ([4e71e04](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4e71e04))
|
||||
* feat(engine): apply per-frame iteration_overrides onto module instance ([10dcac4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/10dcac4))
|
||||
* feat(engine): combine honors hold seed_scope ([8871a28](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8871a28))
|
||||
* feat(engine): fixed_values honors hold seed_scope ([15c1ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/15c1ade))
|
||||
* feat(engine): thread a hold seed and loop index into the pipeline run ([0f7aaab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f7aaab))
|
||||
* feat(engine): wildcard honors hold seed_scope, locked_seed still wins ([072e4e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/072e4e7))
|
||||
* feat(manager): snapshot source/target tag_groups into published constraints ([ff4d8fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff4d8fa))
|
||||
* feat(types): add seed_scope and iteration_overrides to module config ([8910b98](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8910b98))
|
||||
* revert(context): drop per-frame seed capture, superseded by double-shift fix ([91b5f75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/91b5f75))
|
||||
* refactor(context): unify frame-hint markup and add false-case coverage ([8f696d6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8f696d6))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.7.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* feat(engine): add apply_seed_locks for per-iteration seed overrides ([5da64f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5da64f8))
|
||||
* feat(loop): apply per-iteration seed locks in the context loop node ([4884598](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4884598))
|
||||
* feat(loop): per-iteration seeds button + modal; shrink iteration-var buttons ([86eb15c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/86eb15c))
|
||||
* feat(seedlist): add paste-to-replace locks from copied text ([fa73750](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa73750))
|
||||
* feat(seedlist): alt-click copy to grab only the locked seeds ([589f45e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/589f45e))
|
||||
* feat(seedlist): apply per-iteration seed locks to the output series ([fd2cdcb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd2cdcb))
|
||||
* feat(seedlist): flash a copied indicator on the modal copy button ([34ec18f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/34ec18f))
|
||||
* feat(seedlist): per-iteration seeds button + modal ([43d775e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43d775e))
|
||||
* feat(seedlist): preview effective base/count/strategy from the wired loop ([68910bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/68910bf))
|
||||
* feat(seedlist): show out-of-range locks dimmed but still editable ([b49a579](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b49a579))
|
||||
* feat(shared): add per-iteration view/set/lock row (SeedLockRow) ([1ad7e8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ad7e8e))
|
||||
* feat(shared): ts mirror of loop seed derivation with py-ts parity corpus ([cc64ca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cc64ca3))
|
||||
* feat(shared): view/set/lock per-iteration seeds modal ([0f0cd47](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f0cd47))
|
||||
* feat(widgets): add seed_locks field to loop + seed-list configs ([5a3e834](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a3e834))
|
||||
* feat(widgets): feed stock base-seed + count into the seed modal ([fb19e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fb19e3c))
|
||||
* feat(widgets): seed-list override hint + capitalize loop bypass label ([44d8ab9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d8ab9))
|
||||
* test(widgets): restore parse-recovery coverage dropped alongside seed_locks tests ([b5262c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b5262c2))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.6.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(api): keep duplicate children when creating or updating bundles ([c1f45a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c1f45a3))
|
||||
* fix(constraint): drop matrix scroll-container padding that leaked scrolled cells ([d3e25b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3e25b7))
|
||||
* fix(constraint): stop scrolled cells bleeding through the frozen matrix headers ([4098009](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4098009))
|
||||
* fix(manager): decimal option probabilities and distinct sub-category group colors ([11d205b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11d205b))
|
||||
* feat(constraint): group the canvas matrix axes like the SPA ([538db06](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/538db06))
|
||||
* feat(manager): author and save bundles directly in the manager ([a77e60b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a77e60b))
|
||||
* feat(manager): group constraint matrix axes with header chips and column bands ([c0f354c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0f354c))
|
||||
* feat(manager): label the matrix ungrouped axis run as "uncategorized" ([eac852f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eac852f))
|
||||
* feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart ([0bbf150](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bbf150))
|
||||
* feat(manager): type-to-filter all dropdowns ([b3537ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b3537ad))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.5.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* feat(manager): one-time library repair for non-hex module/bundle ids ([02cc9f7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02cc9f7))
|
||||
* fix(import): normalize non-hex module/bundle ids on import + download ([a1d0511](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a1d0511))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.4.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
|
||||
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
|
||||
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
|
||||
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
|
||||
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #2 from DumiFlex/feat/manager-bulk-editor ([6e8696f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6e8696f)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
|
||||
* feat(manager): add bulk editor for wildcard options and fixed values ([959cfbe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/959cfbe))
|
||||
* feat(widgets): shield clipboard shortcuts on editable widget inputs ([325c908](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/325c908))
|
||||
* fix(manager): recover from rich-text editor crash and persist drafts on unload ([0f5166e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f5166e))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.2.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #1 from DumiFlex/feat/community-embed ([a53d526](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a53d526)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
|
||||
* test: lock cascade-restore end-to-end constraint repoint (#2) ([67de90c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67de90c)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
|
||||
* test(cascade): edge-case qa for subcat rename/delete (collapse, null, multi-surface) ([54054fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/54054fb))
|
||||
* test(constraint): double-cast findComponent to VueWrapper in cached-names test ([c102361](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c102361))
|
||||
* test(engine): lock constraint source-pick fallback semantics ([690169b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/690169b))
|
||||
* test(sp2a): lock multi-pick debug render + var.K cascade/validation ([d1ee97b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d1ee97b))
|
||||
* test(spa): lock accessor-aware var autocomplete match ([8d7bda2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8d7bda2))
|
||||
* test(syntax): move subcat_filter tests into tests/syntax for the coverage gate ([3065e99](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3065e99))
|
||||
* test(validators): engine-parity harness to stop validator drift ([51f6793](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51f6793))
|
||||
* test(wp_api): lock comma rejection on variable names server-side ([94ccd7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/94ccd7f))
|
||||
* docs: document schema_version machine + column-add procedure ([ede4f4e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ede4f4e))
|
||||
* docs: flag pick-bucket collision + deprecate dead remapBundleUuids ([96fc26a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/96fc26a))
|
||||
* docs: update constraint conventions to the SP3 reach model ([8314963](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8314963))
|
||||
* docs(constraint): document target reach selectors and axis reattach ([844d16e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/844d16e))
|
||||
* docs(derivation): document nested refs in action values ([402fdcc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/402fdcc))
|
||||
* docs(syntax): document multi-pick, nested-in-inline, $var.K, and the surface gate ([7297b2f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7297b2f))
|
||||
* docs(wildcard): document multi-pick, sub-categories, and nested refs ([8b99795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8b99795))
|
||||
* feat: pick_min/pick_max/pick_separator wildcard instance fields ([17a2149](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/17a2149))
|
||||
* feat(api): modules/hashes returns type + fingerprint per id ([885f7fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885f7fb))
|
||||
* feat(bundles): add extractBundleChildren fresh-id materialization helper ([223770e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/223770e))
|
||||
* feat(bundles): extract bundle children to library with ref remap ([59af070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59af070))
|
||||
* feat(bundles): manual per-child reattach with community download ([f58d849](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f58d849))
|
||||
* feat(bundles): move extract action to children header, used-by beside title ([f1ab33c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f1ab33c))
|
||||
* feat(bundles): relink bundle to extracted modules + gate extract on dangling children ([b1fa0ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b1fa0ce))
|
||||
* feat(canvas): @{} chips + autocomplete in derivation instance modal ([ab813bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ab813bb))
|
||||
* feat(cascade): expression-aware subcat rename/delete (engine + ts) ([11ad8ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11ad8ca)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
|
||||
* feat(cascade): per-entity wildcard-delete cleanup via cleanup_ids ([9a97613](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a97613))
|
||||
* feat(cascade): reconcileSubcatExpr token reconciler for remap preview ([fd25a63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd25a63))
|
||||
* feat(cascade): rewriteBrokenRef pure rewriter for broken-ref remap ([1d59df9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1d59df9))
|
||||
* feat(collision): add type-conflict state + classifyOne core ([9e34d23](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9e34d23))
|
||||
* feat(community): add public post detail + download fetch helpers ([c858bef](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c858bef))
|
||||
* feat(community): add transitive dependency closure walker ([1ee8fe2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ee8fe2))
|
||||
* feat(community): orchestrate transitive dep download for dangling refs ([7dc9817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7dc9817))
|
||||
* feat(community): route Publish through embed instead of new tab ([59c5d70](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59c5d70))
|
||||
* feat(community): surface underlying import error in EmbedLoadError ([6f51e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f51e4a))
|
||||
* feat(community): track install origin + update-available indicator ([9957702](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9957702))
|
||||
* feat(community): update-action dialog with in-place + as-new + dismiss ([af314d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af314d3))
|
||||
* feat(conflicts): surface broken nested @{} refs at module row ([fd254a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd254a6))
|
||||
* feat(constraint): backfill cached wildcard name from live catalog on save ([d188ead](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d188ead))
|
||||
* feat(constraint): cache source/target wildcard names for broken-ref banner ([70da7e0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70da7e0))
|
||||
* feat(constraint): canvas modal detects dangling source/target and reattaches via walkremap ([77f00ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/77f00ae))
|
||||
* feat(constraint): canvas parity for stranded read-only matrix + colored exception modes ([5ae0ff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5ae0ff0))
|
||||
* feat(constraint): download missing deps from community + reattach ([02ad0eb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ad0eb))
|
||||
* feat(constraint): list derivation-nested occurrences in reach pick-list + orphan scan ([098c069](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/098c069))
|
||||
* feat(constraint): lock stranded wildcard fields + color-code exception modes ([4598f68](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4598f68))
|
||||
* feat(constraint): reattach section for dangling source/target with blast-radius warning ([f7369ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f7369ff))
|
||||
* feat(constraint): recognise derivations as @{} carriers (branch-key option ids) ([8247d73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8247d73))
|
||||
* feat(constraint): show stranded constraint matrix + exceptions read-only in spa editor ([fa68a36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa68a36))
|
||||
* feat(constraint): spa editor reattach control mirrors the canvas modal ([1bf1e42](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1bf1e42))
|
||||
* feat(context): type-aware drift/missing + id-clash state via classifyOne ([c13351d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c13351d))
|
||||
* feat(engine): bulk lazy runner — eager iteration of lazy_migrate_row ([73ca8a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73ca8a3))
|
||||
* feat(engine): capture constraint's own bundle_origin on registered meta ([febb9f3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/febb9f3))
|
||||
* feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot ([be1f765](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be1f765))
|
||||
* feat(engine): content_rating column on library rows (migration 015) ([395af63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/395af63))
|
||||
* feat(engine): independent (allow-repeats) multi-pick for the wildcard pool ([33b3600](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33b3600))
|
||||
* feat(engine): lazy_migrate_row — the ONE migration runtime ([07fe3ab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07fe3ab))
|
||||
* feat(engine): list-backed variables + var.K accessor ([8eeb0a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eeb0a8))
|
||||
* feat(engine): migrate library v1->v2 (multi-tag) + bump schema version ([39529a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39529a6))
|
||||
* feat(engine): migration 014 - payload schema_version + verbatim-local mirror ([c46cbbf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c46cbbf))
|
||||
* feat(engine): multi-pick prefix gains N-M range + ~ independent flag ([0b36ce8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0b36ce8))
|
||||
* feat(engine): multi-select wildcard binds a list variable ([443db49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/443db49))
|
||||
* feat(engine): multi-tag options + boolean sub-category filters (v2) ([13de622](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/13de622))
|
||||
* feat(engine): per-pick recording + apply constraints via combine_constraint_factor ([f68d2ec](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f68d2ec))
|
||||
* feat(engine): pure combine_constraint_factor + shared constraint corpus ([dd785c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dd785c5))
|
||||
* feat(engine): range count + independent mode + lone-ref pool multi-pick ([63b824e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63b824e))
|
||||
* feat(engine): record per-origin source pick view in __wp_picks__ ([d6094ed](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d6094ed))
|
||||
* feat(engine): resolve @{} refs on the derivation surface ([059517f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/059517f))
|
||||
* feat(engine): stamp derivation carrier (uid, branch_key) for nested reach ([599c3b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/599c3b1))
|
||||
* feat(engine): thread bundle_origin into resolve ctx per module ([7bcfcac](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7bcfcac))
|
||||
* feat(engine): thread carrier uid + option id for nested-pick constraint reach ([4165b2e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4165b2e))
|
||||
* feat(engine): tokenize var.K list accessor (py+ts parity) ([3ac0afa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3ac0afa))
|
||||
* feat(engine): validate + register constraint target_select; instance override field ([8c3c9ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c3c9ae))
|
||||
* feat(import-export): install decision tree per spec §4 Flow 3 ([19836dd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/19836dd))
|
||||
* feat(import-export): parseTolerantAsCurrentShape — strip newer fields ([4758c84](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4758c84))
|
||||
* feat(import-export): publish stamps schema_version + producer_engine_version ([ad0f8c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ad0f8c2))
|
||||
* feat(import): apply walkremap follow-through over committed entities on rename ([236c39a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/236c39a))
|
||||
* feat(import): surface cross-kind id clashes as CLASH; never replace ([4fabb76](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4fabb76))
|
||||
* feat(import): walkremap follow-through helper for renamed friend ids ([4dd6d6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4dd6d6c))
|
||||
* feat(install): add buildReattachRemap slug-bridge helper ([7845d91](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7845d91))
|
||||
* feat(install): add reattach type surface (deps, slug, count) ([e138868](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e138868))
|
||||
* feat(install): auto-reattach inner-bundle references on bundle install ([b4f2d6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b4f2d6b))
|
||||
* feat(install): auto-reattach references via slug-bridge on install ([0494e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0494e3c))
|
||||
* feat(install): flag + backstop cross-kind id clashes (never replace) ([ca54a3a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ca54a3a))
|
||||
* feat(install): host bridge forwards post deps + library slug ([a9bde14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a9bde14))
|
||||
* feat(install): pre-commit collision detection + resolver callback ([519abd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/519abd3))
|
||||
* feat(manager): add Publish to community button on Export tab ([c6c42c4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6c42c4))
|
||||
* feat(manager): editor footers gain Upload + Copy buttons ([9c0b11f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c0b11f))
|
||||
* feat(manager): embed Wildcard Pipeline Community via runtime bundle ([7d43164](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d43164))
|
||||
* feat(manager): enforce identifier grammar on variable names ([c661550](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c661550))
|
||||
* feat(manager): expose window.__wpcRuntime host bridge for community installs ([a10a321](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a10a321))
|
||||
* feat(manager): mirror v1->v2 migration + bump client schema version ([221aa90](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/221aa90))
|
||||
* feat(manager): move editor Publish + Copy from footer to header ([ea3c628](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea3c628))
|
||||
* feat(manager): nsfw content filter on every list view + all items ([4b491b8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4b491b8))
|
||||
* feat(manager): nsfw toggle in editors + pill on library rows ([99374cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/99374cf))
|
||||
* feat(manager): per-row community upload + copy + update diag ([7a5c382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7a5c382))
|
||||
* feat(manager): real 404 page + global error boundary ([c0cfd13](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0cfd13))
|
||||
* feat(manager): remaprefpopup for healing broken nested chips ([e897bd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e897bd3))
|
||||
* feat(manager): thread module-id so remap clears broken-ref warnings in all #3 hosts ([770909d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/770909d)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* feat(manager): wire @remap in richtextinput to heal the open field ([a75c737](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a75c737))
|
||||
* feat(pool): multi-select pick-count control + null disable in multi ([d167a1f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d167a1f))
|
||||
* feat(publish): add listReferencedUuids dependency detector ([e709d53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e709d53))
|
||||
* feat(publish): auto-detect + inject module dependencies into publish hash ([107d5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/107d5f4))
|
||||
* feat(publish): guided unmet-dependency checklist before publish ([292a0a5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/292a0a5))
|
||||
* feat(publish): surface bundle children external refs as dependencies ([33316aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33316aa))
|
||||
* feat(publish): surface inner-bundle dependencies in guided publish ([e7e844b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e7e844b))
|
||||
* feat(publish): verify published deps + reclassify stale ones to publish-first ([feb760e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/feb760e))
|
||||
* feat(publish): wire inner-bundle dependency publish button to navigate ([f8edcfe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8edcfe))
|
||||
* feat(publish): writeback community origin to local row after publish ([bbd1a37](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bbd1a37))
|
||||
* feat(refchip): distinct remap emit for unresolved broken chips ([43de217](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43de217))
|
||||
* feat(spa): "Allow repeats" toggle for the wildcard pool multi-pick ([e785928](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e785928))
|
||||
* feat(spa): author @{} nested refs in derivation action values ([3564938](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3564938))
|
||||
* feat(spa): canvas wildcard editor decomposes blocks into inner chips (sp2b) ([0962aad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0962aad))
|
||||
* feat(spa): decompose brace blocks into scaffolding + inner chips, render v2 ([ef6fadc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ef6fadc))
|
||||
* feat(spa): render var.K as a single chip + accessor-aware autocomplete ([64d1cb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/64d1cb1))
|
||||
* feat(spa): resolve inline blocks in the combine preview (deterministic) ([62f2e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62f2e4a))
|
||||
* feat(spa): stamp sp2b schema_version on publish when range/independent used ([36942a2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/36942a2))
|
||||
* feat(syntax): boolean sub-category filter parser, matcher + ts parity ([0e1803e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e1803e))
|
||||
* feat(ts): badge reframe — reach suffix, contributor cluster + solid collapse chip, before name ([02ed686](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ed686))
|
||||
* feat(ts): combineConstraintFactor mirror + shared corpus parity ([2eb0e36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2eb0e36))
|
||||
* feat(ts): constraint reach selector — segmented modes + cross-node pick checklist ([c80605a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c80605a))
|
||||
* feat(ts): debug rows for constraint never_applied + partial_reach ([764c82e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/764c82e))
|
||||
* feat(ts): library-default target_select in the manager constraint editor ([a0f9651](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a0f9651))
|
||||
* feat(ts): pairings mark-all reach + contributors (drop exclusive claim) ([3902210](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3902210))
|
||||
* feat(ts): per-selector constraint orphan scan (drop one-shot claim-order) ([3f14a6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f14a6f))
|
||||
* feat(ts): per-WP_Context node_label, tagged through the cross-node walker + editable header chip ([a94cd54](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a94cd54))
|
||||
* feat(ts): preview resolver applies combine + reach (engine parity) ([b20faa0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b20faa0))
|
||||
* feat(ts): stamp schema_version 4 when constraint target_select is non-default ([826d86f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/826d86f))
|
||||
* feat(ts): transitive nested-target carrier walk (cap + cycle) with route chain ([1ddbe45](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ddbe45))
|
||||
* feat(ui): nested-ref filter popover + chip + in-graph pool editor ([33e6bd0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33e6bd0))
|
||||
* feat(ui): wildcard editor sub-category groups + per-option multi-tag ([c374863](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c374863))
|
||||
* feat(validators): v1 strict + tolerant validators per subtype + bundle ([810e33e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/810e33e)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
|
||||
* fix: refresh token field + show update pill outside #name slot ([9c7a110](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c7a110)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
|
||||
* fix(assembler): render list-valued vars + .K accessor in node preview ([dc05a5d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc05a5d))
|
||||
* fix(bundle): remap constraint source/target to restored uuids on cascade push ([bb9ec6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb9ec6d))
|
||||
* fix(bundles): reconcile restored inner-bundle fingerprint to clear false drift ([e458e8d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e458e8d))
|
||||
* fix(bundles): repoint cascade-restored standalone library refs (phase 1 pass 2) ([fa0f2ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa0f2ce))
|
||||
* fix(bundles): repoint workflow module refs on cascade restore, not just ids ([6fe52c0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6fe52c0))
|
||||
* fix(bundles): stamp payload_hash on cascade-restored bundle children so re-insert shows no drift ([eff7a6a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eff7a6a))
|
||||
* fix(bundles): sync restored module payload_hash on cascade so canvas shows no drift ([37ca8bd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/37ca8bd))
|
||||
* fix(canvas): picker z-index + teleport theme + inline ↪#N badge in derivation summary ([ec3508a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ec3508a)), closes [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N) [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N)
|
||||
* fix(cascade): allow whole-entity delete for fixed_values/combine/derivation/constraint ([738ede3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/738ede3))
|
||||
* fix(cascade): discover v2 boolean sub-category filter refs ([4cda98f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4cda98f))
|
||||
* fix(cascade): keep bundles frozen on wildcard rename (no auto-mutate) ([e748064](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e748064)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
|
||||
* fix(cascade): stamp deleted wildcard name onto dependent constraints for broken-ref banner ([97d3036](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97d3036))
|
||||
* fix(cascade): wildcard-delete leaves constraints, opt-in nested-ref strip ([e288d1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e288d1b))
|
||||
* fix(combine): filter multi-pick representative to engine pool + honor .K in template preview ([b9df102](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9df102))
|
||||
* fix(community): preserve bundles/library/deleted views in host nav ([f8efcc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8efcc9))
|
||||
* fix(community): sync embed theme with the manager toggle live ([04841aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04841aa))
|
||||
* fix(community): tokenStore refresh hits /auth/token/refresh not /auth/refresh ([a2cc1bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2cc1bb))
|
||||
* fix(constraint): derive real dropped-cell count for reattach preview ([47af97e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47af97e))
|
||||
* fix(constraint): float cached-name match in reattach dropdown (auto-suggest) ([07dcf31](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07dcf31)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(constraint): keep @{} carrier regex strict-hex (engine/dep-graph parity) ([b27b9f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b27b9f8))
|
||||
* fix(constraint): origin-stamp downloaded deps so they get the community badge ([0984a7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0984a7f))
|
||||
* fix(context): constraint-reach QA, node codenames, bundle-aware toolbar ([73a00a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73a00a3))
|
||||
* fix(engine): bind constraint to its own bundle copy's source pick ([9071dfb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9071dfb))
|
||||
* fix(engine): fold list-valued vars in derivation + converter reads ([d41be86](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d41be86))
|
||||
* fix(engine): guard junk pick counts + warn on multi-pick constraint source ([ace0486](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ace0486))
|
||||
* fix(engine): slugify nested-ref tags in v1->v2 migrate + sp1 e2e smoke ([a628512](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a628512))
|
||||
* fix(graph): honor .K accessor + multi-pick representative in static preview ([f922dca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f922dca))
|
||||
* fix(import-export): refresh also reloads the export tab listing ([7b2d057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b2d057))
|
||||
* fix(install): drop normalizeEntity now that community speaks engine-row shape ([b9132af](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9132af))
|
||||
* fix(install): lift meta.name to top-level name in install pipeline ([160b729](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/160b729))
|
||||
* fix(library): validate all-items bundles against loaded catalog not empty store ([c66c1f5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c66c1f5))
|
||||
* fix(manager): add Delete button to WildcardEditor ([3d92673](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3d92673))
|
||||
* fix(manager): anchor remap popup to the chip using its real height ([498a4ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/498a4ba))
|
||||
* fix(manager): bundle-editor constraint names + list keyboard nav reachability ([0da118e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0da118e)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(manager): community pill lifecycle, list keyboard nav, dashboard intro ([24e83ea](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/24e83ea)), closes [4/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
|
||||
* fix(manager): community routes share layoutKey to stop embed re-mount ([6f2b804](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f2b804))
|
||||
* fix(manager): confirm before destructive actions ([bb82b66](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb82b66))
|
||||
* fix(manager): dashboard tabs left + settings restart confirm ([b85f21a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b85f21a)), closes [#actions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/actions)
|
||||
* fix(manager): dismiss subcat menus on outside click + flip near edge + drag-to-regroup ([723adde](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/723adde))
|
||||
* fix(manager): keep editor header Publish + Copy grouped ([97c92b9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97c92b9))
|
||||
* fix(manager): never chipify on backspace/delete in rich-text editor ([af90203](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af90203))
|
||||
* fix(manager): parseRoute handles :rest(.*)* array params ([84cd82f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/84cd82f))
|
||||
* fix(manager): re-anchor remap popup on resize + cap height to viewport ([3884ded](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3884ded))
|
||||
* fix(manager): render unsettled $var/@ref as plain text in editor ([bf189b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf189b3))
|
||||
* fix(manager): row actions/focus follow hover+keyboard, not a sticky state ([915e0cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/915e0cc))
|
||||
* fix(pool): custom pick steppers + min<=max clamp + null-aware enabled/probability ([2a24f9f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a24f9f))
|
||||
* fix(pool): strike + uncheck null row in multi-pick; align pick stepper styling ([7d267d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d267d0))
|
||||
* fix(pool): surface multi-pick count in pool summary + instance subtitle ([31fe070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/31fe070))
|
||||
* fix(preview): serialize list-valued vars in resolve endpoint ([6703583](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6703583))
|
||||
* fix(publish): bypass vue-router hash encoder ([63a7cd9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63a7cd9))
|
||||
* fix(publish): correct fixed_values shape + strip history sidecar ([191f880](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/191f880))
|
||||
* fix(publish): forward content_rating + tags + origin slug to embed ([39c0637](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39c0637))
|
||||
* fix(publish): forward schema_version stamp into the community publish hash ([9a3b5fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a3b5fa))
|
||||
* fix(publish): normalize bundle children to module-row shape ([47b1535](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47b1535))
|
||||
* fix(publish): stamp constraint source/target wildcard names so shares self-describe ([50612dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/50612dc))
|
||||
* fix(resolver): carry structured list-vars through static resolve so $mood.0 indexes ([435cdb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/435cdb1))
|
||||
* fix(schema): accept natively-supported future versions on install (max-known, not chain head) ([9f7ee12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9f7ee12))
|
||||
* fix(schema): advertise max-known schema version to publish gate, not chain head ([0cb5706](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0cb5706))
|
||||
* fix(snapshot): include SP2a pick fields in instance baseline + snapshot type ([d3881c8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3881c8))
|
||||
* fix(spa): $var autocomplete fires after the $$ multi-pick delimiter ([3a9a09a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3a9a09a))
|
||||
* fix(spa): block native delete eating the ZWSP pad at field boundaries ([2a1123f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a1123f))
|
||||
* fix(spa): chip whitespace-padded block arms + recolour blocks on edit ([51a526e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51a526e)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(spa): colour rich-text syntax on the ComfyUI canvas ([ba455fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ba455fb))
|
||||
* fix(spa): detect @{uuid} refs nested inside brace/multi blocks ([6097590](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6097590))
|
||||
* fix(spa): drop leftover background box behind multi-pick blocks ([b9ae37a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9ae37a))
|
||||
* fix(spa): keep autocomplete open across a $var.K accessor (no Enter newline split) ([d9235c3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d9235c3))
|
||||
* fix(spa): keep brace-block scaffolding colour through atom-direct edits ([619cd5a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/619cd5a)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* fix(spa): resolve \.K to base name in derivation validation + conflict scan ([bc7ea33](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bc7ea33))
|
||||
* fix(spa): show rich-text placeholder ghost (zwsp pad defeated :empty) ([e69ceae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e69ceae))
|
||||
* fix(spa): show the !null ban mark on canvas inner-block ref chips ([55d9ef1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/55d9ef1))
|
||||
* fix(spa): single-char backspace/delete on raw-typed $var (atomic-delete only committed chips) ([743f8b0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/743f8b0))
|
||||
* fix(spa): stable $var.K chip round-trip in rich-text editor ([1208222](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1208222))
|
||||
* fix(ts): pick reach stores bare _uid; partial_reach severity info + stale-comment cleanup ([d826c6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d826c6b))
|
||||
* fix(ts): thread cross-node chain to the constraint modal (chain-modules prop) ([71e05b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/71e05b1))
|
||||
* fix(ui): anchor ref-filter popover snug to its chip + move delete button far-left ([ff0f9dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff0f9dc))
|
||||
* fix(ui): clamp ref-filter popover on-screen, show match count, link null toggle to exclude_null ([b23e29e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b23e29e))
|
||||
* fix(ui): peel !null in nested-ref filter chips on canvas + preview ([c5720e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c5720e7))
|
||||
* fix(ui): render embed/canvas checkboxes via a scoped WpCheck component ([8000e1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8000e1b))
|
||||
* fix(ui): shrink ref-filter action buttons by overriding the fixed .wp-btn height ([c777810](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c777810))
|
||||
* fix(ui): stack the pool allow-repeats + exclude-null toggles ([8ad2d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ad2d0c))
|
||||
* fix(ui): style wildcard editor sub-category groups + option-tag picker ([3bcafc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3bcafc9))
|
||||
* fix(ui): subcategory polish across spa + graph (colours, exclude-null, palette, popover) ([c2d0ef5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c2d0ef5)), closes [1/#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [9/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
|
||||
* fix(validation): parse v2 boolean sub-category ref filters ([5a0e05e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a0e05e))
|
||||
* fix(validators): match real engine shapes + widget-native bundle children ([8a9ee8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9ee8c))
|
||||
* refactor(bundles): delete dead remapBundleUuids, keep walkRemap ([a4d1913](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a4d1913))
|
||||
* refactor(constraint): centralise derivation branch-key format + py/ts corpus parity ([0e9b5cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9b5cf))
|
||||
* refactor(engine): extract pick_k_unique from multi-pick resolver ([e5f54c6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e5f54c6))
|
||||
* refactor(import-export): per-row migratePayload contract per spec §2 ([320315c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/320315c))
|
||||
* refactor(install): applyCollisionDecisions returns rename map ([a5f95cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a5f95cf))
|
||||
* refactor(manager): harmonize module-list row pills ([87b8c6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/87b8c6d))
|
||||
* refactor(ui): convert remaining native checkboxes to the shared wp-check look ([c6baeb8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6baeb8))
|
||||
* refactor(ui): make .wp-check the one checkbox style + restyle to wildcard-option look ([62d3768](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62d3768))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.1.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* feat(api): add GET /wp/api/database/info ([dc839bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc839bf))
|
||||
* feat(api): add POST /wp/api/database/maintenance ([0909817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0909817))
|
||||
* feat(api): expose db config get/put endpoints with pending-move ([04d3795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04d3795))
|
||||
* feat(api): run pending db move before migration on startup ([00b0c73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b0c73))
|
||||
* feat(engine): add analyze maintenance op ([d39add1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d39add1))
|
||||
* feat(engine): add gather_info DB introspection ([2b5f2b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2b5f2b7))
|
||||
* feat(engine): add integrity_check maintenance op ([7f12575](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7f12575))
|
||||
* feat(engine): add run_migrations maintenance op ([5a62929](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a62929))
|
||||
* feat(engine): add sidecar db-config.json reader/writer ([069527a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/069527a))
|
||||
* feat(engine): add user/global/root location path helpers ([747a84a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/747a84a))
|
||||
* feat(engine): add vacuum maintenance op ([4abe679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4abe679))
|
||||
* feat(engine): execute pending db moves at plugin load ([52966e6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/52966e6))
|
||||
* feat(engine): expose db path resolution source ([7e12626](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7e12626))
|
||||
* feat(engine): honor sidecar preference and add root db location ([c03fc07](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c03fc07))
|
||||
* feat(manager): add always-visible restart comfyui row to maintenance section ([c6a1030](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6a1030))
|
||||
* feat(manager): add database API client + types ([f20a9e3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f20a9e3))
|
||||
* feat(manager): add database card with info and maintenance ([df7eb75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df7eb75))
|
||||
* feat(manager): add databaseStore Pinia store ([2003f38](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2003f38))
|
||||
* feat(manager): add db config types and api client methods ([8c760f2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c760f2))
|
||||
* feat(manager): add location selector with copy or move flow ([eb48d7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eb48d7f))
|
||||
* feat(manager): add maintenance op three-phase modal ([7cfe29b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7cfe29b))
|
||||
* feat(manager): add restart comfyui button via manager reboot endpoint ([02c8300](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02c8300))
|
||||
* feat(manager): browser preferences card with full prefix coverage ([b2b75d4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b2b75d4))
|
||||
* feat(manager): detect server restart and prompt stale page reload ([7102057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7102057))
|
||||
* feat(manager): extend database store with config and pending move actions ([8eb5967](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eb5967))
|
||||
* feat(manager): fade routes on tab switch with reduced-motion fallback ([9a053e8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a053e8))
|
||||
* feat(manager): heartbeat poll every 30s so stale banner appears without tab switch ([efb38ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/efb38ff))
|
||||
* feat(manager): replace storage card with browser prefs and database cards ([885a2d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885a2d0))
|
||||
* fix(manager): flex-based row layout for database info, wrap long paths ([a89bf7d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a89bf7d))
|
||||
* fix(manager): probe manager via get /manager/version instead of head reboot ([843a679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/843a679))
|
||||
* chore: ignore db-config.json sidecar and root db dir ([d23a728](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d23a728))
|
||||
* chore(tests): fix fresh_db fixture return type annotation ([ecdbf6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ecdbf6c))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.0.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
@@ -44,8 +44,8 @@ ruff check . # Python lint
|
||||
- **Toast singleton** — one Vue app mounted to body-level div renders all toasts pushed via `src/components/shared/toast-store.ts`. Every Context node import same store so toasts surface from anywhere. Position mirror ComfyUI native PrimeVue toast anchor (rect-relative to `.graph-canvas-container`).
|
||||
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
|
||||
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — no available target instance DOWNSTREAM, count-aware: N constraints targeting same wildcard need N downstream instances). Engine emits parallel runtime warning `constraint_never_applied` (warning) when a constraint's target never came up.
|
||||
- **Constraint first-instance** — each constraint module is a one-shot. Fires on the FIRST target wildcard instance encountered downstream at runtime (including via nested `@{uuid}` refs), then is consumed. Engine tracks consumption in `ctx["__wp_consumed_constraints__"]` (set keyed by the owning constraint module's id). To affect multiple target instances, author multiple constraint modules. Position in chain controls which target each constraint claims — no Local/Global / Stack/Override scope modes.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — the constraint's reach selector covers ZERO reachable downstream instances; per-selector, NOT count-aware — overlapping constraints may freely share an instance). Engine emits runtime warnings `constraint_never_applied` (the reach matched nothing at runtime) and `constraint_partial_reach` (a `next N` / `pick` reach matched fewer instances than requested).
|
||||
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
|
||||
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
|
||||
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
|
||||
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
|
||||
@@ -65,6 +65,87 @@ ruff check . # Python lint
|
||||
- **Reactivity** `extension/reactive.ts:reactiveFromGraph` — wires `onConnectionsChange` chain + 400ms polling fallback + `afterConfigureGraph` re-sync so widgets recompute on graph edits + workflow loads without flashing stale state.
|
||||
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). Use `import("./widgets/<name>")` so stay out of entry chunk + size gate keep holding.
|
||||
|
||||
## Schema versioning + lazy migration machine
|
||||
|
||||
Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-schema-versioning-migration-design.md` (cross-repo lock; read before bumping).
|
||||
|
||||
**Three axes** every payload + library row carries:
|
||||
|
||||
1. **`schema_version`** (per row, engine column added in migration 014): which payload shape this row's `payload` is in. Single version per payload root — bundles' children inherit the bundle's `schema_version`.
|
||||
2. **`producer_engine_version`** (per published community version, server-side only): diagnostic. Never load-bearing.
|
||||
3. **`version_number`** (per community post, server-side): the user-facing 1/2/3 publish revision. Unrelated to schema_version.
|
||||
|
||||
**Engine row columns** (migration 014):
|
||||
- `schema_version INTEGER NOT NULL DEFAULT 1` — current shape this row targets.
|
||||
- `original_payload_json TEXT` — verbatim local mirror of the payload as it arrived from community (or import). Lazy migrator reads from here, never from the live `payload` field. Lets us re-project from the original if a migrator chain gets fixed retroactively.
|
||||
- `tolerant_drift_status TEXT` — `none` | `tolerant` | `strict`. Set by parseTolerantAsCurrentShape during install.
|
||||
- `schema_migrated_at TEXT` — wall-clock stamp of last lazy migration step.
|
||||
|
||||
**The machine**:
|
||||
|
||||
- **Validator registry** (`src/manager/import-export/validators/`): one Zod validator per known `schema_version`. Strict at `≤ CURRENT_SCHEMA_VERSION`; future versions go through `parseTolerantAsCurrentShape` (additive-strip).
|
||||
- **`parseTolerantAsCurrentShape(raw, currentVersion)`** (`src/manager/import-export/tolerant-parse.ts`): strips fields the current shape doesn't know, returns the projection that matches `currentVersion`. Used at install when source `schema_version > CURRENT`.
|
||||
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migration-machine.ts`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
|
||||
- **`lazy_migrate_row(row, currentVersion, migrators, validators)`** (`engine/db/lazy_migrate.py`): reads `original_payload_json` (or `payload` if absent), walks the migrator chain to `currentVersion`, validates, writes back. Bulk-at-boot runner is eager iteration of this routine.
|
||||
- **Install decision tree** (`src/manager/import-export/install.ts`): branches on `source_schema_version` vs `CURRENT_SCHEMA_VERSION`:
|
||||
- `=`: strict validate + insert.
|
||||
- `<`: migratePayload then strict validate + insert.
|
||||
- `>` and tolerant-only diff: parseTolerantAsCurrentShape, mark `tolerant_drift_status='tolerant'`, insert.
|
||||
- `>` and breaking-future diff (per catalog `is_breaking_from_previous` AND-fold across the interval): refuse install with a clear error.
|
||||
|
||||
**Server probe**: `engine-export-wrap.ts:ENGINE_SCHEMA_VERSION` is the sister's pinned CURRENT — it ships in the sister bundle, not fetched from server. The host bridge install path reads it from `window.__wpcRuntime.schemaVersion`. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's CURRENT.
|
||||
|
||||
### Bumping `schema_version` (the proper way)
|
||||
|
||||
When a payload shape change lands (not a row-column change — those don't bump schema):
|
||||
|
||||
1. **Add the new shape's validator.** New file under `src/manager/import-export/validators/v<N>.ts` exporting a Zod schema. Register in `validators/index.ts`.
|
||||
2. **Add the migrator step.** In `src/manager/import-export/migration-machine.ts`, register `migrators[N-1 → N]: (payload) => ...`. Forward-only. The function is pure — no side effects.
|
||||
3. **Add fixtures.** `src/manager/__tests__/import-export/fixtures/v<N>/` — at minimum: a `valid-strict.json`, a `from-v<N-1>-migrated.json` round-trip case, and one `breaking-shape.json` if the bump is breaking.
|
||||
4. **Bump the constant.** `CURRENT_SCHEMA_VERSION` lives in the validator registry. Bump it to `N`. Also bump `ENGINE_SCHEMA_VERSION` in `src/manager/components/engine-export-wrap.ts`.
|
||||
5. **Write the migration test.** `tests/engine/db/test_lazy_migrate.py` should cover v<N-1>→v<N> round-trip + the breaking-future path if applicable.
|
||||
6. **Run `pytest tests/engine/ -q && pnpm test` until green.** Sister pre-commit hook (~2 min) also runs lint + typecheck + build + size; expect that wait.
|
||||
7. **Coordinate the community-side catalog row.** Community web's `schema_catalog` table gets the matching `(version, is_breaking_from_previous, notes)` row + a deploy that lands BEFORE sister's PR merges. See community CLAUDE.md "Bumping the schema" for the server side.
|
||||
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's CURRENT, sister can safely refuse breaking-future shapes.
|
||||
|
||||
### Validator ↔ engine parity (DON'T skip)
|
||||
|
||||
The community TS validators in `src/validators/` are a hand-authored
|
||||
re-implementation of the engine's payload shapes, and re-implementations
|
||||
drift — fixed_values shipped for weeks as `entries/{variable_name}` when
|
||||
the engine has always used `values/{id,name,value}`. The guard against
|
||||
this:
|
||||
|
||||
- `scripts/dump_engine_shapes.py` builds one sample payload per subtype,
|
||||
validates each against that subtype's **own engine `Handler.validate_payload`**
|
||||
(the authority — raises if not engine-valid), and writes the engine-row
|
||||
shape to `src/validators/fixtures/engine-parity/*.json`.
|
||||
- `src/validators/__tests__/engine-parity.test.ts` asserts the **strict**
|
||||
community validator accepts every one of those fixtures.
|
||||
|
||||
**Any time you change an engine module payload shape** (a `*_handler.py`
|
||||
`validate_payload`, or what the editor stores), run
|
||||
`python scripts/dump_engine_shapes.py` and commit the regenerated
|
||||
fixtures. If the sample no longer validates against the handler, the
|
||||
script fails — update the sample. If the TS validator then rejects the
|
||||
new shape, the parity test fails — update the validator. Either failure
|
||||
means the two have diverged; fix it before publish breaks for a user.
|
||||
|
||||
### Row-level column changes (no schema_version bump needed)
|
||||
|
||||
The most recent example is **migration 015 (`content_rating` column)**. Adding a column to `modules` or `bundles` is just an Alembic-style SQLite migration — no payload-shape change, no validator update, no migrator chain. Procedure:
|
||||
|
||||
1. **New file** `engine/db/migrations_sql/<NNN>_<name>.py` mirroring 013/015 shape: `_has_column` gate + `with conn: conn.execute("ALTER TABLE … ADD COLUMN …")` for both `modules` and `bundles` if the field applies to both.
|
||||
2. **Update `_row_to_module` / `_row_to_bundle`** in `engine/db/repositories.py` — wrap the new column read in `try/except (IndexError, KeyError)` so pre-migration fixtures don't blow up.
|
||||
3. **Update `ModuleRepository.create/update` + `BundleRepository.create/update`** to accept the new field as a kwarg.
|
||||
4. **Update `_insert_module` / `_insert_bundle` (+ `_update_module` / `_update_bundle` if needed)** in `engine/importer.py` to pass the field through from the entity dict. Use `COALESCE(?, col)` on the UPDATE side when you want missing-in-content to preserve existing value (NOT overwrite to NULL).
|
||||
5. **Update `wp_api/modules.py` + `wp_api/bundles.py`**: add the field to `_UPDATABLE_FIELDS` + plumb into the create handlers.
|
||||
6. **TS types**: extend `ModuleRow` / `BundleRow` + `*CreateInput` / `*UpdateInput` in `src/manager/api/types.ts`.
|
||||
7. **Bump the head-migration test**: `tests/engine/db/test_migrations.py:test_migrate_records_version` asserts the head version — update to `<NNN>`. Same for the expected-columns set tests.
|
||||
8. **Write a migration test** at `tests/engine/db/test_migration_<NNN>.py` — column exists, default backfill correct, idempotent rerun, accepts the new values.
|
||||
|
||||
If the column also flows through the host-bridge install seam (community origin metadata), extend `InstallOrigin` in `src/manager/import-export/install.ts` AND the embed's `HostInstallOpts.origin` in `web/src/embed/EmbedDetail.vue` (community web repo). Map community-side naming → engine-side naming at the seam if they differ (e.g. `'sfw'` → `'safe'`).
|
||||
|
||||
## Anti-patterns (inherited from reference project `ComfyUI-Wildcard-Pipeline`)
|
||||
|
||||
- Never use V1 `NODE_CLASS_MAPPINGS` / `EXTENSION_WEB_DIRS`.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/docs/flow-intro.svg" alt="Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP" />
|
||||
<img src="public/images/docs/flow-intro.svg" alt="Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits named $variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning" />
|
||||
</p>
|
||||
|
||||
## What it does
|
||||
@@ -33,6 +33,7 @@ Stock ComfyUI prompt nodes either take a single literal string or pick from a fl
|
||||
- **Persistent library** — a manager SPA backed by SQLite stores reusable modules, frozen bundles, prompt templates, and a category tree. Browse / edit / fork / push from the canvas.
|
||||
- **Inspector** — `WP Debug` shows the post-run snapshot, per-module trace, per-wildcard picks, and the conflict-scanner warnings.
|
||||
- **Type-coercion helpers** — `WP Var → Int / Float / Bool` parse typed values out of any `$variable` so wildcards can drive image width, step count, sampler cfg, conditional switches.
|
||||
- **Works on both renderers** — the legacy canvas and ComfyUI's Nodes 2.0 Vue renderer, including switching between them with a workflow already open.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -105,7 +106,7 @@ Full module reference: [Modules wiki page](https://github.com/DumiFlex/ComfyUI-W
|
||||
## Looping a chain — one click, N coherent prompts
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/docs/flow-loop.svg" alt="WP Context Loop + WP Seed List → KSampler — both lists fan out in lockstep" />
|
||||
<img src="public/images/docs/flow-loop.svg" alt="WP Context Loop drives WP Context and the Prompt Assembler; CLIP Encode turns each prompt into conditioning for KSampler, while WP Seed List supplies the matching seed directly" />
|
||||
</p>
|
||||
|
||||
Drop `WP Context Loop` before your `WP Context` and pair it with `WP Seed List` for distinct sampler seeds. Both lists fan out in lockstep so iteration N's prompt always pairs with seed N. Each iteration carries its own `$iteration` / `$iteration_total` for in-template labelling (`frame 1 of 4 — a fox`). See [Concepts → Seeds and loops](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Concepts#seeds-and-loops).
|
||||
@@ -145,7 +146,7 @@ See [Nodes → WP Debug](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/w
|
||||
|
||||
## Status
|
||||
|
||||
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every PR.
|
||||
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every branch push and PR.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+253
-85
@@ -37,6 +37,9 @@ import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine.db.repositories import BundleRepository, ModuleRepository
|
||||
from engine.syntax.subcat_filter import ParseError as _ParseError
|
||||
from engine.syntax.subcat_filter import parse as _parse_subcat
|
||||
from engine.syntax.subcat_filter import reads_as as _reads_as
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
@@ -59,24 +62,112 @@ def _rewrite_var_in_string(s: str, old_name: str, new_name: str) -> str:
|
||||
return pattern.sub(f"${new_name}", s)
|
||||
|
||||
|
||||
def _map_tags(ast: dict[str, Any] | None, fn) -> dict[str, Any] | None:
|
||||
"""Apply *fn* to each tag leaf of a parsed sub-category expression;
|
||||
drop tags *fn* maps to ``None`` and collapse the now-childless
|
||||
operator (a one-child and/or becomes that child). Returns ``None``
|
||||
when the whole expression empties. Mirror of TS `mapTags`
|
||||
(src/manager/cascade/subcatExprCascade.ts)."""
|
||||
if ast is None:
|
||||
return None
|
||||
if "tag" in ast:
|
||||
v = fn(ast["tag"])
|
||||
return None if v is None else {"tag": v}
|
||||
if ast["op"] == "not":
|
||||
x = _map_tags(ast["x"], fn)
|
||||
return {"op": "not", "x": x} if x is not None else None
|
||||
kids = [k for k in (_map_tags(c, fn) for c in ast["kids"]) if k is not None]
|
||||
if not kids:
|
||||
return None
|
||||
return kids[0] if len(kids) == 1 else {"op": ast["op"], "kids": kids}
|
||||
|
||||
|
||||
def _expr_tags(ast: dict[str, Any] | None) -> set[str]:
|
||||
out: set[str] = set()
|
||||
|
||||
def walk(n: dict[str, Any] | None) -> None:
|
||||
if n is None:
|
||||
return
|
||||
if "tag" in n:
|
||||
out.add(n["tag"])
|
||||
return
|
||||
if n["op"] == "not":
|
||||
walk(n["x"])
|
||||
return
|
||||
for k in n["kids"]:
|
||||
walk(k)
|
||||
|
||||
walk(ast)
|
||||
return out
|
||||
|
||||
|
||||
def collect_tags(expr: str | None) -> set[str]:
|
||||
"""Distinct sub-category tags referenced by a filter *expr* string.
|
||||
|
||||
Mirror of TS ``collectTags`` (src/manager/cascade/subcatExprCascade.ts).
|
||||
Empty or malformed expressions yield the empty set — the caller simply
|
||||
won't link the ref; ``validate_expression`` surfaces real syntax errors
|
||||
elsewhere. Used by the discovery scan so a rename/delete of any one tag
|
||||
finds every ref whose boolean filter mentions it (negated tags count).
|
||||
"""
|
||||
if not expr or not expr.strip():
|
||||
return set()
|
||||
try:
|
||||
ast = _parse_subcat(expr)
|
||||
except _ParseError:
|
||||
return set()
|
||||
return _expr_tags(ast)
|
||||
|
||||
|
||||
def _ref_expr_pattern(wildcard_id: str) -> re.Pattern[str]:
|
||||
# 4-segment ref to a specific wildcard: groups = (#name, :expr, !null).
|
||||
return re.compile(
|
||||
r"@\{" + re.escape(wildcard_id)
|
||||
+ r"(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
|
||||
)
|
||||
|
||||
|
||||
def _rewrite_ref_expr(s: str, wildcard_id: str, target: str, fn) -> str:
|
||||
"""Rewrite the boolean ``:expr`` of every ``@{wildcard_id...}`` ref
|
||||
whose expression contains *target*, mapping its tags via *fn*
|
||||
(rename, or remove + collapse). Refs that don't mention *target* are
|
||||
left verbatim — no cosmetic re-serialization. ``#name`` + ``!null``
|
||||
are preserved; an expression that empties drops the ``:expr``
|
||||
segment, collapsing ``@{uuid:cold}`` to a bare ``@{uuid}``."""
|
||||
pattern = _ref_expr_pattern(wildcard_id)
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
name, expr, null = m.group(1), m.group(2), m.group(3)
|
||||
if expr is None or not expr.strip():
|
||||
return m.group(0)
|
||||
ast = _parse_subcat(expr)
|
||||
if target not in _expr_tags(ast):
|
||||
return m.group(0)
|
||||
new_expr = _reads_as(_map_tags(ast, fn))
|
||||
out = "@{" + wildcard_id + (f"#{name}" if name else "")
|
||||
if new_expr:
|
||||
out += ":" + new_expr
|
||||
if null == "null":
|
||||
out += "!null"
|
||||
return out + "}"
|
||||
|
||||
return pattern.sub(repl, s)
|
||||
|
||||
|
||||
def _rewrite_subcat_ref_in_string(
|
||||
s: str,
|
||||
wildcard_id: str,
|
||||
old_subcat: str,
|
||||
new_subcat: str,
|
||||
) -> str:
|
||||
"""Replace ``@{wildcard_id[#name]:old_subcat}`` →
|
||||
``@{wildcard_id[#name]:new_subcat}``. Preserves any optional
|
||||
``#name`` segment so a subcat rename doesn't strip the wildcard's
|
||||
cached display name from refs."""
|
||||
pattern = re.compile(
|
||||
r"@\{" + re.escape(wildcard_id)
|
||||
+ r"(#[^#:}@{]*)?:" + re.escape(old_subcat) + r"\}"
|
||||
"""Rename ``old_subcat`` -> ``new_subcat`` inside every
|
||||
``@{wildcard_id...}`` ref's boolean expression (e.g.
|
||||
``@{u:warm or cold}`` -> ``@{u:warm or chilly}``). Structure,
|
||||
``#name``, and ``!null`` are preserved."""
|
||||
return _rewrite_ref_expr(
|
||||
s, wildcard_id, old_subcat,
|
||||
lambda t: new_subcat if t == old_subcat else t,
|
||||
)
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
name_seg = m.group(1) or ""
|
||||
return f"@{{{wildcard_id}{name_seg}:{new_subcat}}}"
|
||||
return pattern.sub(repl, s)
|
||||
|
||||
|
||||
def _strip_subcat_ref_in_string(
|
||||
@@ -84,13 +175,38 @@ def _strip_subcat_ref_in_string(
|
||||
wildcard_id: str,
|
||||
subcat_name: str,
|
||||
) -> str:
|
||||
"""Remove ``@{wildcard_id[#name]:subcat_name}`` occurrences from
|
||||
*s*. Matches with or without the optional ``#name`` segment."""
|
||||
pattern = re.compile(
|
||||
r"@\{" + re.escape(wildcard_id)
|
||||
+ r"(?:#[^#:}@{]*)?:" + re.escape(subcat_name) + r"\}"
|
||||
"""Remove ``subcat_name`` from every ``@{wildcard_id...}`` ref's
|
||||
expression, collapsing operators (``@{u:warm or cold}`` ->
|
||||
``@{u:warm}``); a ref whose expression empties collapses to the bare
|
||||
``@{wildcard_id}`` (the nested insertion survives, only its filter
|
||||
is dropped)."""
|
||||
return _rewrite_ref_expr(
|
||||
s, wildcard_id, subcat_name,
|
||||
lambda t: None if t == subcat_name else t,
|
||||
)
|
||||
return pattern.sub("", s)
|
||||
|
||||
|
||||
def _strip_whole_ref_in_string(s: str, wildcard_id: str) -> str:
|
||||
"""Remove every whole ``@{wildcard_id...}`` token from *s*.
|
||||
|
||||
Unlike ``_strip_subcat_ref_in_string`` (which only drops the
|
||||
``:expr`` filter, collapsing to a bare ``@{uuid}``), this deletes the
|
||||
entire nested-insertion token — used by ``fix_wildcard_delete`` when
|
||||
the referenced wildcard itself is gone, so the insertion can't
|
||||
resolve at all. Surrounding text is preserved; whitespace left at the
|
||||
seam is collapsed (doubled spaces -> single) and the result trimmed,
|
||||
so ``"warm @{u} glow"`` -> ``"warm glow"`` rather than
|
||||
``"warm glow"``.
|
||||
|
||||
Reuses the shared 4-segment ref pattern (``_ref_expr_pattern``) so the
|
||||
``#name`` / ``:expr`` / ``!null`` variants of the token all match.
|
||||
"""
|
||||
pattern = _ref_expr_pattern(wildcard_id)
|
||||
stripped = pattern.sub("", s)
|
||||
if stripped == s:
|
||||
return s
|
||||
# Collapse whitespace left at the seam, then trim edges.
|
||||
return re.sub(r"[ \t]{2,}", " ", stripped).strip()
|
||||
|
||||
|
||||
def _rewrite_ref_name_in_string(
|
||||
@@ -124,21 +240,40 @@ def _rewrite_ref_name_in_string(
|
||||
def fix_wildcard_delete(
|
||||
conn: sqlite3.Connection,
|
||||
wildcard_id: str,
|
||||
cleanup_ids: list[str],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Strip all references to *wildcard_id* from constraints.
|
||||
"""Opt-in strip of nested ``@{wildcard_id...}`` refs to a deleted wildcard.
|
||||
|
||||
* Constraints whose source or target is *wildcard_id* are deleted —
|
||||
they reference the wildcard by id only, with no fallback shape.
|
||||
Constraints are never DELETED here. They reference the wildcard by id
|
||||
only and are healable via the reattach UI — the cascade leaves them
|
||||
broken so the user can re-point them later, rather than deleting them
|
||||
(the old behavior, removed). The ONE constraint mutation that does run
|
||||
is a display-only name stamp: the deleted wildcard's name is cached onto
|
||||
every dependent constraint's ``source_wildcard_name`` /
|
||||
``target_wildcard_name`` (the last moment the live name is known), so the
|
||||
broken-reference banner shows "(was …)" instead of a bare uuid. The
|
||||
id/matrix/exceptions stay untouched. The stamp is in `touched` (undo) but
|
||||
not in `diff` (it's not a ref removal). It runs regardless of cleanup_ids.
|
||||
|
||||
Bundles are intentionally NOT touched. Bundle children are full
|
||||
frozen snapshots: the wildcard's payload, options, and meta were
|
||||
deep-copied into ``bundle.children[i]`` at insert time, so the
|
||||
bundle keeps working even when the source library row is deleted.
|
||||
Removing the snapshot would destroy data the user explicitly
|
||||
captured for offline / portable use. The MISSING badge on the
|
||||
referenced workflow rows still fires (drift-store sees the id is
|
||||
gone) — that's the honest signal; stripping the snapshot would be
|
||||
over-eager housekeeping.
|
||||
Nested ``@{wildcard_id...}`` refs (the deleted wildcard inserted
|
||||
inside ANOTHER wildcard's option value, or a derivation action's
|
||||
string value) are stripped ONLY for the specific entity ids the
|
||||
caller lists in *cleanup_ids*. Every entity not in *cleanup_ids* is
|
||||
left untouched — its broken ref survives and is healed later via a
|
||||
remap. An empty/absent *cleanup_ids* therefore strips nothing: only
|
||||
the wildcard row itself is dropped (by the orchestrator), and all
|
||||
constraints + nested refs survive.
|
||||
|
||||
For each entity in *cleanup_ids*:
|
||||
* ``wildcard`` — every ``@{wildcard_id...}`` token is removed from
|
||||
each ``options[].value`` string (whole token, surrounding text
|
||||
preserved).
|
||||
* ``derivation`` — same strip applied to every string value inside
|
||||
``rules[].branches[].actions[]`` (mirrors ``_scan_wildcard_delete``).
|
||||
|
||||
Bundles are intentionally NOT touched (unchanged): bundle children
|
||||
are full frozen snapshots, so the bundle keeps resolving with its
|
||||
captured payload even when the source library row is deleted.
|
||||
|
||||
The fixer does NOT delete the wildcard row itself; the orchestrator
|
||||
owns that mutation.
|
||||
@@ -148,16 +283,78 @@ def fix_wildcard_delete(
|
||||
|
||||
mod_repo = ModuleRepository(conn)
|
||||
|
||||
# --- Constraints --------------------------------------------------------
|
||||
# Stamp the about-to-be-deleted wildcard's name onto every dependent
|
||||
# constraint's source/target name cache, BEFORE the orchestrator drops the
|
||||
# row. This is the last moment the live name is known; without it a
|
||||
# constraint broken by the deletion shows only a bare uuid in the reattach
|
||||
# banner ("Source wildcard 1e9c0e2c is not in your library"). Display-only:
|
||||
# the id/matrix/exceptions are untouched and the constraint is never deleted
|
||||
# (still healable via reattach). Recorded in `touched` for undo but NOT
|
||||
# emitted as a `diff` entry — it's not a ref removal, so it must not inflate
|
||||
# affected_count. Runs regardless of cleanup_ids (the empty-cleanup
|
||||
# early-return is below).
|
||||
target = mod_repo.get(wildcard_id)
|
||||
wc_name = target.get("name") if target else None
|
||||
if wc_name:
|
||||
for c in mod_repo.list():
|
||||
if c["type"] != "constraint":
|
||||
continue
|
||||
cp = c.get("payload") or {}
|
||||
new_cp = copy.deepcopy(cp)
|
||||
stamped = False
|
||||
if cp.get("source_wildcard_id") == wildcard_id:
|
||||
if cp.get("source_wildcard_name") != wc_name:
|
||||
new_cp["source_wildcard_name"] = wc_name
|
||||
stamped = True
|
||||
if cp.get("target_wildcard_id") == wildcard_id:
|
||||
if cp.get("target_wildcard_name") != wc_name:
|
||||
new_cp["target_wildcard_name"] = wc_name
|
||||
stamped = True
|
||||
if stamped:
|
||||
touched.append(_deepcopy_row(c))
|
||||
mod_repo.update(c["id"], payload=new_cp)
|
||||
|
||||
cleanup = set(cleanup_ids)
|
||||
if not cleanup:
|
||||
return touched, diff
|
||||
|
||||
for m in mod_repo.list():
|
||||
if m["type"] != "constraint":
|
||||
if m["id"] not in cleanup:
|
||||
continue
|
||||
t = m["type"]
|
||||
p = m.get("payload") or {}
|
||||
if (p.get("source_wildcard_id") == wildcard_id
|
||||
or p.get("target_wildcard_id") == wildcard_id):
|
||||
new_payload = copy.deepcopy(p)
|
||||
changed = False
|
||||
|
||||
if t == "wildcard":
|
||||
for opt in new_payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _strip_whole_ref_in_string(v, wildcard_id)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
changed = True
|
||||
|
||||
elif t == "derivation":
|
||||
for rule in new_payload.get("rules") or []:
|
||||
for branch in rule.get("branches") or []:
|
||||
for action in branch.get("actions") or []:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
for k, v in list(action.items()):
|
||||
if isinstance(v, str):
|
||||
new_v = _strip_whole_ref_in_string(v, wildcard_id)
|
||||
if new_v != v:
|
||||
action[k] = new_v
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
touched.append(_deepcopy_row(m))
|
||||
mod_repo.delete(m["id"])
|
||||
diff.append({"entity_id": m["id"], "removed": True})
|
||||
mod_repo.update(m["id"], payload=new_payload)
|
||||
diff.append({
|
||||
"entity_id": m["id"],
|
||||
"remove_ref": {"kind": "wildcard", "wildcard_id": wildcard_id},
|
||||
})
|
||||
|
||||
return touched, diff
|
||||
|
||||
@@ -193,11 +390,12 @@ def fix_subcat_delete(
|
||||
s for s in declared if s != subcat_name
|
||||
]
|
||||
changed = True
|
||||
# Per-option singular assignment — null out options pointing at
|
||||
# the deleted subcat (preserves the option, releases the link)
|
||||
# Per-option membership — drop the deleted subcat from each
|
||||
# option's sub_categories list (preserves the option).
|
||||
for opt in new_payload.get("options") or []:
|
||||
if opt.get("sub_category") == subcat_name:
|
||||
opt["sub_category"] = None
|
||||
subs = opt.get("sub_categories")
|
||||
if isinstance(subs, list) and subcat_name in subs:
|
||||
opt["sub_categories"] = [s for s in subs if s != subcat_name]
|
||||
changed = True
|
||||
|
||||
elif t == "constraint":
|
||||
@@ -270,10 +468,13 @@ def fix_subcat_rename(
|
||||
new_name if s == old_name else s for s in declared
|
||||
]
|
||||
changed = True
|
||||
# Per-option singular assignment (`opt.sub_category`, not plural)
|
||||
# Per-option membership list (`opt.sub_categories`)
|
||||
for opt in new_payload.get("options") or []:
|
||||
if opt.get("sub_category") == old_name:
|
||||
opt["sub_category"] = new_name
|
||||
subs = opt.get("sub_categories")
|
||||
if isinstance(subs, list) and old_name in subs:
|
||||
opt["sub_categories"] = [
|
||||
new_name if s == old_name else s for s in subs
|
||||
]
|
||||
changed = True
|
||||
|
||||
elif t == "constraint":
|
||||
@@ -335,9 +536,12 @@ def fix_wildcard_rename_name(
|
||||
Combine templates, fixed-values, and constraint payloads don't hold
|
||||
``@{}`` text refs (constraints reference by id only via
|
||||
``source_wildcard_id`` / ``target_wildcard_id``), so they're
|
||||
skipped. Bundle children carry frozen module snapshots whose option
|
||||
values may contain refs; we walk those too so a save propagates
|
||||
into bundle copies.
|
||||
skipped. Bundle children are frozen point-in-time snapshots and are
|
||||
intentionally NOT rewritten (mirrors ``fix_wildcard_delete``): a rename
|
||||
leaves the bundle's cached ``#name`` / refs stale until the user
|
||||
explicitly refreshes the bundle. Otherwise editing ANY module would
|
||||
silently mutate every bundle holding a snapshot of it — a bundle must
|
||||
stay a fixed snapshot, not auto-track its source modules.
|
||||
|
||||
Skips the renamed wildcard itself — its own name lives on the row,
|
||||
not in its own option values.
|
||||
@@ -346,7 +550,6 @@ def fix_wildcard_rename_name(
|
||||
diff: list[dict[str, Any]] = []
|
||||
|
||||
mod_repo = ModuleRepository(conn)
|
||||
bundle_repo = BundleRepository(conn)
|
||||
|
||||
for m in mod_repo.list():
|
||||
if m["id"] == wildcard_id:
|
||||
@@ -390,45 +593,10 @@ def fix_wildcard_rename_name(
|
||||
},
|
||||
})
|
||||
|
||||
# Bundle child snapshots — walk frozen module copies and rewrite
|
||||
# ref strings in their option values. Children may reference the
|
||||
# renamed wildcard via @{uuid} in their stored snapshots.
|
||||
for b in bundle_repo.list():
|
||||
children = b.get("children") or []
|
||||
new_children = copy.deepcopy(children)
|
||||
bundle_changed = False
|
||||
for ch in new_children:
|
||||
if not isinstance(ch, dict):
|
||||
continue
|
||||
if ch.get("type") != "wildcard":
|
||||
continue
|
||||
if ch.get("id") == wildcard_id:
|
||||
# The renamed wildcard's own snapshot in this bundle —
|
||||
# update the cached `name` field instead of refs.
|
||||
if ch.get("name") != new_name:
|
||||
ch["name"] = new_name
|
||||
bundle_changed = True
|
||||
continue
|
||||
payload = ch.get("payload") or {}
|
||||
for opt in payload.get("options") or []:
|
||||
v = opt.get("value")
|
||||
if isinstance(v, str):
|
||||
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
|
||||
if new_v != v:
|
||||
opt["value"] = new_v
|
||||
bundle_changed = True
|
||||
if bundle_changed:
|
||||
touched.append(_deepcopy_row(b))
|
||||
bundle_repo.update(b["id"], children=new_children)
|
||||
diff.append({
|
||||
"entity_id": b["id"],
|
||||
"rename_ref": {
|
||||
"kind": "wildcard_name",
|
||||
"wildcard_id": wildcard_id,
|
||||
"new": new_name,
|
||||
},
|
||||
})
|
||||
|
||||
# Bundle children are frozen snapshots — intentionally NOT rewritten
|
||||
# here (see docstring). They drift; the explicit bundle-refresh flow
|
||||
# re-snapshots them on demand, so a module edit never auto-mutates a
|
||||
# bundle.
|
||||
return touched, diff
|
||||
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ def _rename_subcat_target_only(
|
||||
"""Rename a subcat inside the source wildcard ONLY (no cascade to refs).
|
||||
|
||||
Touches both the top-level `payload.sub_categories` declared list and
|
||||
each option's singular `sub_category` field. The plural-array shape
|
||||
that the prior version operated on does not exist in real data — see
|
||||
`engine/modules/wildcard_handler.py:206` for the actual schema.
|
||||
each option's `sub_categories` membership list (SP1 multi-tag shape).
|
||||
"""
|
||||
mod = ModuleRepository(conn)
|
||||
wc = mod.get(wildcard_id)
|
||||
@@ -71,8 +69,11 @@ def _rename_subcat_target_only(
|
||||
new_options = []
|
||||
for opt in payload.get("options") or []:
|
||||
new_opt = dict(opt)
|
||||
if new_opt.get("sub_category") == old_name:
|
||||
new_opt["sub_category"] = new_name
|
||||
subs = new_opt.get("sub_categories")
|
||||
if isinstance(subs, list) and old_name in subs:
|
||||
new_opt["sub_categories"] = [
|
||||
new_name if s == old_name else s for s in subs
|
||||
]
|
||||
new_options.append(new_opt)
|
||||
payload["options"] = new_options
|
||||
|
||||
@@ -181,7 +182,12 @@ def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, An
|
||||
key = (kind, action)
|
||||
|
||||
if key == ("wildcard", "delete"):
|
||||
touched_before, diff = fix_wildcard_delete(conn, target_id)
|
||||
# Opt-in nested-ref cleanup: only entities the caller
|
||||
# lists get their @{wildcard_id...} token stripped.
|
||||
# Constraints are never deleted (healed via reattach UI);
|
||||
# an empty list strips nothing.
|
||||
cleanup_ids = req.get("cleanup_ids") or []
|
||||
touched_before, diff = fix_wildcard_delete(conn, target_id, cleanup_ids)
|
||||
elif key == ("subcategory", "delete"):
|
||||
touched_before, diff = fix_subcat_delete(
|
||||
conn, target_id, extra.get("subcat_name", "")
|
||||
@@ -202,16 +208,46 @@ def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, An
|
||||
touched_before, diff = fix_option_delete(
|
||||
conn, extra.get("wildcard_id", ""), target_id,
|
||||
)
|
||||
elif action == "delete" and kind in (
|
||||
"fixed_values",
|
||||
"combine",
|
||||
"derivation",
|
||||
"constraint",
|
||||
):
|
||||
# These module types are leaves from a reverse-ref
|
||||
# perspective: nothing in another row's payload points
|
||||
# at them by id the way `option.id` is referenced from
|
||||
# constraint matrices or `wildcard.id` is referenced
|
||||
# from constraint source/target. A whole-entity delete
|
||||
# is just a row drop + undo snapshot — no fix step,
|
||||
# no diff entries.
|
||||
#
|
||||
# Caveat: combine.output_var IS referenced by name
|
||||
# from downstream payloads, but the cascade pipeline
|
||||
# treats that as a "rename" concern (already wired as
|
||||
# combine_output_var/rename above). Deleting a combine
|
||||
# leaves any consumer referencing the output_var with
|
||||
# an unresolved variable, surfaced separately by the
|
||||
# integrity/broken-refs scan.
|
||||
touched_before, diff = [], []
|
||||
else:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"unsupported (kind, action) pair: ({kind}, {action})",
|
||||
}
|
||||
|
||||
# Delete target entity for whole-entity delete ops.
|
||||
# Subcategory deletes are NOT in this set — the fixer already
|
||||
# mutated the source wildcard's payload; there is no separate row.
|
||||
if action == "delete" and kind in ("wildcard", "category", "bundle"):
|
||||
# Delete target entity for whole-entity delete ops. Module
|
||||
# subtypes route through ModuleRepository via _delete_target
|
||||
# (the else-branch of its dispatch).
|
||||
if action == "delete" and kind in (
|
||||
"wildcard",
|
||||
"category",
|
||||
"bundle",
|
||||
"fixed_values",
|
||||
"combine",
|
||||
"derivation",
|
||||
"constraint",
|
||||
):
|
||||
target_snapshot = _delete_target(conn, kind, target_id)
|
||||
touched_before.append(target_snapshot)
|
||||
diff.append({"entity_id": target_id, "removed": True})
|
||||
|
||||
+21
-10
@@ -17,14 +17,18 @@ import re
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine.cascade.fixers import collect_tags
|
||||
from engine.db.repositories import ModuleRepository
|
||||
|
||||
# ``@{8hex}``, ``@{8hex#name}``, ``@{8hex:subcat}``, or
|
||||
# ``@{8hex#name:subcat}`` — uuid + optional cached display name +
|
||||
# optional subcat filter. Mirrors `engine/syntax/tokenize.py:_REF_RE`
|
||||
# and the TS twin in `src/manager/cascade/dep-graph.ts`. Name segment
|
||||
# is informational; this scanner only ever reads the captured uuid.
|
||||
_REF_REGEX = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
|
||||
# 4-segment ref: ``@{8hex [#name] [:expr] [!null]}`` — uuid + optional
|
||||
# cached display name + optional boolean sub-category filter expression
|
||||
# + optional exclude-null marker. Mirrors `engine/syntax/tokenize.py:_REF_RE`
|
||||
# and the TS twin in `src/manager/cascade/dep-graph.ts`. findall groups:
|
||||
# (uuid, name, expr, null-marker). This scanner reads the uuid; the
|
||||
# subcat scan also reads the raw expr (see `_scan_subcat`).
|
||||
_REF_REGEX = re.compile(
|
||||
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
|
||||
)
|
||||
# ``$varname`` — leading-letter identifier
|
||||
_VAR_REGEX = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
@@ -66,7 +70,7 @@ def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[di
|
||||
v = opt.get("value")
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
if any(uid == wildcard_id for uid, _name, _sub in _REF_REGEX.findall(v)):
|
||||
if any(m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)):
|
||||
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
|
||||
break
|
||||
continue
|
||||
@@ -85,7 +89,7 @@ def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[di
|
||||
continue
|
||||
for v in action.values():
|
||||
if isinstance(v, str) and any(
|
||||
uid == wildcard_id for uid, _name, _sub in _REF_REGEX.findall(v)
|
||||
m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)
|
||||
):
|
||||
out.append(_ref_entry(
|
||||
"derivation", m,
|
||||
@@ -149,8 +153,15 @@ def _scan_subcat(
|
||||
v = opt.get("value")
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
for uid, _name, sub in _REF_REGEX.findall(v):
|
||||
if uid == wildcard_id and sub == subcat_name:
|
||||
# SP1: `sub` is the raw `:expr` segment (the 4-segment regex
|
||||
# already split `!null` into the null group). A boolean filter
|
||||
# like `warm or cold` references multiple tags, so parse it and
|
||||
# test tag membership — the same set the fixer rewrites
|
||||
# (engine/cascade/fixers.py). Exact-string equality would miss
|
||||
# every multi-tag / negated / parenthesized filter and make the
|
||||
# preview undercount what the apply actually changes.
|
||||
for uid, _name, sub, _null in _REF_REGEX.findall(v):
|
||||
if uid == wildcard_id and subcat_name in collect_tags(sub):
|
||||
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
|
||||
break
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Prompt-syntax atoms the cleaner must not rewrite.
|
||||
|
||||
The cleaner's rules treat a prompt as prose or as a comma-separated tag list.
|
||||
That is the right model for tags, and the wrong one for the handful of tokens
|
||||
that are *addresses* rather than words — `embedding:name` and `<lora:name:1.0>`
|
||||
resolve to a file on disk, so a single edited character silently changes what
|
||||
the sampler loads, or loads nothing at all.
|
||||
|
||||
Two rules were provably destructive (P3 #31, pinned by
|
||||
`tests/engine/cleaner/test_embedding_roundtrip.py`):
|
||||
|
||||
* punctuation stripping — `_` and `-` are in the stripped set, so
|
||||
`embedding:bad_prompt_` (a real published shape) became
|
||||
`embedding:bad_prompt`. ComfyUI resolves a missing embedding to nothing,
|
||||
so the negative prompt quietly stopped working, with no error.
|
||||
* fuzzy dedupe — versioned names are near-identical strings and the
|
||||
threshold is 0.9, so `embedding:badhandv4, embedding:badhandv5` lost the
|
||||
second. Two different files, one silently dropped.
|
||||
|
||||
This widens the cleaner's remit from "text" to "knows a little prompt syntax",
|
||||
which the findings called out as the cost of fixing it. The alternative was
|
||||
leaving a rule that corrupts a file reference without saying so.
|
||||
|
||||
Deliberately narrow: only the well-known forms, and only whole tokens. Anything
|
||||
not matched here keeps its existing treatment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# `embedding:name`, optionally weighted / parenthesised by the caller. The name
|
||||
# runs to the next separator: whitespace, comma, or a closing paren/angle.
|
||||
_EMBEDDING_RE = re.compile(r"\bembedding:[^\s,)\]>]+", re.IGNORECASE)
|
||||
|
||||
# `<lora:name:1.0>` and the siblings that share its shape. Matched whole, so
|
||||
# internal punctuation and decimal weights are untouchable.
|
||||
_ANGLE_RE = re.compile(
|
||||
r"<\s*(?:lora|lyco|locon|hypernet|embedding)\s*:[^>]*>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def contains_atom(text: str) -> bool:
|
||||
"""True when `text` carries a file-reference token cleaning must preserve."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(_EMBEDDING_RE.search(text) or _ANGLE_RE.search(text))
|
||||
|
||||
|
||||
def ends_with_atom(text: str) -> bool:
|
||||
"""True when `text` ENDS in an atom, so trimming its trailing edge would
|
||||
eat part of the reference. Used by the text-mode punctuation pass, which
|
||||
trims the whole string rather than per-tag."""
|
||||
if not text:
|
||||
return False
|
||||
stripped = text.rstrip()
|
||||
for pattern in (_EMBEDDING_RE, _ANGLE_RE):
|
||||
for match in pattern.finditer(stripped):
|
||||
if match.end() == len(stripped):
|
||||
return True
|
||||
return False
|
||||
@@ -8,6 +8,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from engine.cleaner.atoms import contains_atom
|
||||
from engine.cleaner.types import RuleResult
|
||||
|
||||
_FUZZY_THRESHOLD = 0.9
|
||||
@@ -74,7 +75,17 @@ def apply_fuzzy(text: str, mode: str, config: dict) -> RuleResult:
|
||||
for tag in tags:
|
||||
key = tag.casefold()
|
||||
is_dup = False
|
||||
# Versioned file references are near-identical strings by design --
|
||||
# `embedding:badhandv4` vs `...v5` scores well above the 0.9 threshold
|
||||
# while naming two different files. Exact dedupe still collapses a
|
||||
# genuinely repeated one; only the FUZZY pass is held back.
|
||||
tag_is_atom = contains_atom(tag)
|
||||
for kept_tag in kept:
|
||||
if tag_is_atom or contains_atom(kept_tag):
|
||||
if key == kept_tag.casefold():
|
||||
is_dup = True
|
||||
break
|
||||
continue
|
||||
if _ratio(key, kept_tag.casefold()) >= _FUZZY_THRESHOLD:
|
||||
is_dup = True
|
||||
break
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from engine.cleaner.atoms import contains_atom, ends_with_atom
|
||||
from engine.cleaner.types import RuleResult
|
||||
|
||||
_PUNCT_CHARS = r".,:;!?\-_"
|
||||
@@ -37,6 +38,13 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
if _PURE_PUNCT_RE.match(tag):
|
||||
stripped += 1
|
||||
continue
|
||||
# `embedding:name` / `<lora:name:1.0>` are file references, and
|
||||
# `_` and `-` are in the stripped set — trimming an edge renames
|
||||
# the file. ComfyUI resolves a missing embedding to nothing, so
|
||||
# the damage is silent. See engine/cleaner/atoms.py.
|
||||
if contains_atom(tag):
|
||||
kept.append(tag)
|
||||
continue
|
||||
edged, changed = _strip_edges(tag)
|
||||
if changed:
|
||||
stripped += 1
|
||||
@@ -44,7 +52,14 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
kept.append(edged)
|
||||
out = ", ".join(kept)
|
||||
else:
|
||||
out, changed = _strip_edges(text)
|
||||
if changed:
|
||||
stripped += 1
|
||||
# Text mode trims the whole string's edges rather than per tag, so the
|
||||
# only risk is a prompt that ENDS in an atom.
|
||||
if ends_with_atom(text):
|
||||
out = _LEADING_PUNCT_RE.sub("", text)
|
||||
if out != text:
|
||||
stripped += 1
|
||||
else:
|
||||
out, changed = _strip_edges(text)
|
||||
if changed:
|
||||
stripped += 1
|
||||
return {"text": out, "stats": {"stripped": stripped}}
|
||||
|
||||
@@ -10,6 +10,8 @@ Each function:
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
from engine.syntax.types import deref_var_value, split_var_accessor
|
||||
|
||||
|
||||
class _CtxLike(Protocol):
|
||||
context: dict[str, object]
|
||||
@@ -80,7 +82,6 @@ def lookup_var(context: _CtxLike, name: str) -> str:
|
||||
bare = (name or "").lstrip("$").strip()
|
||||
if not bare:
|
||||
return ""
|
||||
value = context.context.get(bare) if context and getattr(context, "context", None) else None
|
||||
if value is None:
|
||||
return ""
|
||||
return value if isinstance(value, str) else str(value)
|
||||
base, index = split_var_accessor(bare)
|
||||
raw = context.context.get(base) if context and getattr(context, "context", None) else None
|
||||
return deref_var_value(raw, index)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Sidecar config for DB location preference + pending move state.
|
||||
|
||||
Lives at ``<plugin>/db-config.json`` so the resolver can read it BEFORE
|
||||
opening any DB connection (chicken-and-egg: the preference about where
|
||||
the DB lives can't live inside the DB itself).
|
||||
|
||||
Atomic writes: write-to-temp-then-rename so a crash mid-write can't
|
||||
corrupt the sidecar.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
Preference = Literal["user", "global", "root"]
|
||||
MoveMode = Literal["copy", "move"]
|
||||
|
||||
# Plugin root: this file is at <plugin>/engine/db/config.py — go up three.
|
||||
_PLUGIN_ROOT = Path(__file__).resolve().parents[2]
|
||||
SIDECAR_PATH = _PLUGIN_ROOT / "db-config.json"
|
||||
|
||||
_VALID_PREFERENCES = {"user", "global", "root"}
|
||||
_VALID_MODES = {"copy", "move"}
|
||||
|
||||
|
||||
class PendingMove(TypedDict, total=False):
|
||||
from_: str # NB: "from" is a Python keyword; we map this on read/write
|
||||
to: str
|
||||
mode: MoveMode
|
||||
|
||||
|
||||
class DbConfig(TypedDict, total=False):
|
||||
preference: Preference
|
||||
pending_move: dict[str, str] # {"from": "...", "to": "...", "mode": "..."}
|
||||
|
||||
|
||||
def _read_raw(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_sidecar(path: Path | None) -> Path:
|
||||
"""Resolve the effective sidecar path.
|
||||
|
||||
Reads ``SIDECAR_PATH`` from this module dynamically when ``path`` is
|
||||
``None`` so tests can ``monkeypatch.setattr("engine.db.config.SIDECAR_PATH", ...)``
|
||||
and have all three of ``load``/``save``/``clear_pending_move`` honor
|
||||
the override. Using a default argument like ``path = SIDECAR_PATH``
|
||||
captures the value at function-definition time, which breaks
|
||||
monkeypatching the module attribute."""
|
||||
return path if path is not None else SIDECAR_PATH
|
||||
|
||||
|
||||
def load(path: Path | None = None) -> DbConfig:
|
||||
"""Return the sidecar config or {} if absent / unreadable / invalid."""
|
||||
raw = _read_raw(_resolve_sidecar(path))
|
||||
out: DbConfig = {}
|
||||
pref = raw.get("preference")
|
||||
if isinstance(pref, str) and pref in _VALID_PREFERENCES:
|
||||
out["preference"] = pref # type: ignore[typeddict-item]
|
||||
pm = raw.get("pending_move")
|
||||
if isinstance(pm, dict):
|
||||
mode = pm.get("mode")
|
||||
src = pm.get("from")
|
||||
dst = pm.get("to")
|
||||
if (isinstance(mode, str) and mode in _VALID_MODES
|
||||
and isinstance(src, str) and isinstance(dst, str)):
|
||||
out["pending_move"] = {"from": src, "to": dst, "mode": mode}
|
||||
return out
|
||||
|
||||
|
||||
def save(config: DbConfig, path: Path | None = None) -> None:
|
||||
"""Atomically write the sidecar (write-temp + rename)."""
|
||||
path = _resolve_sidecar(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Strip any unknown keys; only persist the validated shape.
|
||||
out: dict[str, Any] = {}
|
||||
if "preference" in config:
|
||||
out["preference"] = config["preference"]
|
||||
pm = config.get("pending_move")
|
||||
if isinstance(pm, dict) and {"from", "to", "mode"} <= pm.keys():
|
||||
out["pending_move"] = {"from": pm["from"], "to": pm["to"], "mode": pm["mode"]}
|
||||
fd, tmp_str = tempfile.mkstemp(prefix=".db-config-", dir=str(path.parent))
|
||||
tmp_path = Path(tmp_str)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
os.replace(tmp_path, path)
|
||||
except OSError:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def clear_pending_move(path: Path | None = None) -> None:
|
||||
"""Remove just the pending_move field; preserve preference."""
|
||||
path = _resolve_sidecar(path)
|
||||
cfg = load(path)
|
||||
if "pending_move" in cfg:
|
||||
cfg.pop("pending_move", None)
|
||||
save(cfg, path)
|
||||
|
||||
|
||||
def plugin_root() -> Path:
|
||||
"""Expose the plugin root so the resolver can compute the `root` location."""
|
||||
return _PLUGIN_ROOT
|
||||
+105
-35
@@ -2,22 +2,28 @@
|
||||
|
||||
DB path resolution order (highest priority first):
|
||||
|
||||
1. ``WP_DB_PATH`` env var — explicit override, used as-is.
|
||||
1. ``WP_DB_PATH`` env var — explicit override, used as-is. Source ``"WP_DB_PATH"``.
|
||||
2. ``COMFYUI_USER_DIR`` env var — places the DB under
|
||||
``$COMFYUI_USER_DIR/wildcard-pipeline.db``.
|
||||
3. ComfyUI's user directory, detected at runtime. The detector tries
|
||||
``folder_paths.get_user_directory()`` first (canonical inside the
|
||||
ComfyUI process), then walks up from ``__file__`` to find the
|
||||
ComfyUI root and appends ``user/``. This is the default for any
|
||||
user running the plugin inside a normal ComfyUI install.
|
||||
4. Legacy path ``~/.comfyui/wildcard-pipeline.db`` — kept as a
|
||||
backward-compat fallback. If a DB already exists at the legacy
|
||||
path AND the detected ComfyUI user dir has none, the resolver
|
||||
returns the legacy path so existing installs don't silently
|
||||
start with an empty DB. Run with ``WP_DB_PATH`` (or copy the
|
||||
file) to migrate to the new location.
|
||||
``$COMFYUI_USER_DIR/wildcard-pipeline.db``. Source ``"COMFYUI_USER_DIR"``.
|
||||
3. Sidecar ``preference`` field at ``<plugin>/db-config.json`` (loaded
|
||||
indirectly through ``_load_sidecar`` so tests can stub it):
|
||||
- ``"user"`` → ComfyUI user dir + ``wildcard-pipeline.db``,
|
||||
source ``"user"``. Falls through if the user dir can't be detected.
|
||||
- ``"global"`` → ``~/.comfyui/wildcard-pipeline.db``, source ``"global"``.
|
||||
- ``"root"`` → ``<plugin>/db/wildcard-pipeline.db``, source ``"root"``.
|
||||
4. Default — ComfyUI user dir + ``wildcard-pipeline.db``, source ``"user"``.
|
||||
Falls through if detection fails.
|
||||
5. Last-ditch fallback — ``~/.comfyui/wildcard-pipeline.db``, source
|
||||
``"global"`` (the design folds the old ``"legacy"`` label into
|
||||
``"global"`` since the path is identical and the UI exposes a Global
|
||||
radio for it).
|
||||
|
||||
Tests can short-circuit the entire chain by setting ``WP_DB_PATH``.
|
||||
The pre-feature back-compat clause that preferred legacy when the new
|
||||
path had no DB has been removed — the design intentionally surprises
|
||||
users with an empty new DB so they migrate explicitly via Settings.
|
||||
|
||||
Tests can short-circuit the entire chain by setting ``WP_DB_PATH`` or by
|
||||
stubbing ``_load_sidecar`` to return a chosen preference.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -68,39 +74,103 @@ def _comfyui_user_dir_from_path() -> Path | None:
|
||||
|
||||
|
||||
def _legacy_home_path() -> Path:
|
||||
"""Return ``~/.comfyui/wildcard-pipeline.db``.
|
||||
|
||||
Still named ``_legacy_*`` for historical reasons but now serves as
|
||||
both the explicit ``global`` preference target and the last-ditch
|
||||
fallback when no ComfyUI user dir can be detected. Both report
|
||||
source ``"global"``."""
|
||||
return Path.home() / ".comfyui" / DB_FILENAME
|
||||
|
||||
|
||||
def resolve_db_path() -> Path:
|
||||
# 1. Explicit override.
|
||||
def _plugin_root_db_path() -> Path:
|
||||
"""Return ``<plugin>/db/wildcard-pipeline.db`` — the ``root``
|
||||
preference target. Lives inside the plugin install dir so it
|
||||
survives ComfyUI reinstalls; vulnerable to plugin git pulls that
|
||||
wipe untracked files (called out in the UI as "not recommended")."""
|
||||
from engine.db.config import plugin_root
|
||||
|
||||
return plugin_root() / "db" / DB_FILENAME
|
||||
|
||||
|
||||
def _load_sidecar() -> dict:
|
||||
"""Indirection over ``engine.db.config.load`` so tests can stub the
|
||||
sidecar read without touching the real ``<plugin>/db-config.json``
|
||||
file."""
|
||||
from engine.db.config import load
|
||||
|
||||
return dict(load())
|
||||
|
||||
|
||||
def user_location_path() -> Path | None:
|
||||
"""Return the ``user`` preference target path, or ``None`` if the
|
||||
ComfyUI user dir can't be detected.
|
||||
|
||||
Same detector chain that ``resolve_db_path_with_source`` uses for the
|
||||
``user`` branch. Exposed for the SPA Settings API so the frontend can
|
||||
list all three potential locations regardless of which one is
|
||||
currently active (or whether one is detectable at all)."""
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
return (comfy_user / DB_FILENAME) if comfy_user else None
|
||||
|
||||
|
||||
def global_location_path() -> Path:
|
||||
"""Return the ``global`` preference target — ``~/.comfyui/wildcard-pipeline.db``.
|
||||
|
||||
Always available regardless of host detection (just ``Path.home()``)."""
|
||||
return _legacy_home_path()
|
||||
|
||||
|
||||
def root_location_path() -> Path:
|
||||
"""Return the ``root`` preference target — ``<plugin>/db/wildcard-pipeline.db``."""
|
||||
return _plugin_root_db_path()
|
||||
|
||||
|
||||
def resolve_db_path_with_source() -> tuple[Path, str]:
|
||||
"""Like ``resolve_db_path`` but also reports which resolver rule won.
|
||||
|
||||
Source values:
|
||||
- ``"WP_DB_PATH"`` — explicit env override
|
||||
- ``"COMFYUI_USER_DIR"`` — env user-dir
|
||||
- ``"user"`` — ComfyUI user dir (sidecar preference or default)
|
||||
- ``"global"`` — ~/.comfyui/wildcard-pipeline.db
|
||||
- ``"root"`` — <plugin>/db/wildcard-pipeline.db
|
||||
"""
|
||||
override = os.environ.get("WP_DB_PATH")
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path(override), "WP_DB_PATH"
|
||||
|
||||
# 2. COMFYUI_USER_DIR env var. Honour even when the directory
|
||||
# doesn't exist yet — get_connection() will mkdir before opening.
|
||||
user_dir_env = os.environ.get("COMFYUI_USER_DIR")
|
||||
if user_dir_env:
|
||||
return Path(user_dir_env) / DB_FILENAME
|
||||
return Path(user_dir_env) / DB_FILENAME, "COMFYUI_USER_DIR"
|
||||
|
||||
# 3. ComfyUI user dir via API or path traversal. Both detectors
|
||||
# return None if they can't find a usable directory; we fall
|
||||
# through to the legacy path in that case.
|
||||
sidecar = _load_sidecar()
|
||||
preference = sidecar.get("preference")
|
||||
if preference == "global":
|
||||
return _legacy_home_path(), "global"
|
||||
if preference == "root":
|
||||
return _plugin_root_db_path(), "root"
|
||||
if preference == "user":
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
if comfy_user is not None:
|
||||
return comfy_user / DB_FILENAME, "user"
|
||||
# Detection failed — fall through to the default chain rather
|
||||
# than crash. Last-ditch fallback below will catch it.
|
||||
|
||||
# Default — same as explicit "user" preference.
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
if comfy_user is not None:
|
||||
new_path = comfy_user / DB_FILENAME
|
||||
# 4. Backward-compat: if the new ComfyUI-user location has no
|
||||
# DB yet but the legacy ~/.comfyui location does, prefer the
|
||||
# legacy file so existing installs keep their data without a
|
||||
# silent reset. Users can copy the file across (or set
|
||||
# WP_DB_PATH) when they're ready to migrate.
|
||||
legacy = _legacy_home_path()
|
||||
if not new_path.exists() and legacy.exists():
|
||||
return legacy
|
||||
return new_path
|
||||
return comfy_user / DB_FILENAME, "user"
|
||||
|
||||
# 5. Standalone / no-ComfyUI fallback.
|
||||
return _legacy_home_path()
|
||||
# Last-ditch fallback. The design folds the old "legacy" label into
|
||||
# "global" because the path is identical and the UI has no separate
|
||||
# "legacy" radio.
|
||||
return _legacy_home_path(), "global"
|
||||
|
||||
|
||||
def resolve_db_path() -> Path:
|
||||
"""Path-only convenience wrapper — preserved for all existing callers."""
|
||||
return resolve_db_path_with_source()[0]
|
||||
|
||||
|
||||
def get_connection(path: Path | None = None) -> sqlite3.Connection:
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Read-only DB introspection + safe maintenance operations.
|
||||
|
||||
Pure Python — no ComfyUI imports, no aiohttp. Each public function takes
|
||||
a ``sqlite3.Connection`` and returns a dict. Expected-failure modes
|
||||
(locked DB, integrity errors) are reported via ``ok: False`` payloads,
|
||||
not exceptions, so the API layer can pass results through verbatim.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from engine.db.migrations import _discover, current_version
|
||||
|
||||
# Map module-row "type" column values to the human/UI-facing names used
|
||||
# in the counts response. Kept in one place so future kinds get a single
|
||||
# insertion point.
|
||||
_KIND_TYPES: dict[str, str] = {
|
||||
"wildcards": "wildcard",
|
||||
"fixed_values": "fixed_values",
|
||||
"combines": "combine",
|
||||
"derivations": "derivation",
|
||||
"constraints": "constraint",
|
||||
}
|
||||
|
||||
# Maps the UI-facing count key to the real SQLite table name. Some
|
||||
# tables have a `module_` prefix in storage but show up unprefixed in
|
||||
# the response (e.g. `module_categories` -> "categories") to keep the
|
||||
# settings card readable.
|
||||
_TABLE_COUNTS: dict[str, str] = {
|
||||
"bundles": "bundles",
|
||||
"templates": "templates",
|
||||
"categories": "module_categories",
|
||||
}
|
||||
|
||||
|
||||
def _iso(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _count_modules_by_type(conn: sqlite3.Connection, type_value: str) -> int:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM modules WHERE type = ?", (type_value,)
|
||||
).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _count_table(conn: sqlite3.Connection, table: str) -> int:
|
||||
try:
|
||||
row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
# Table might not exist on very old DBs that haven't migrated yet.
|
||||
return 0
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _pragma_int(conn: sqlite3.Connection, name: str) -> int:
|
||||
row = conn.execute(f"PRAGMA {name}").fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def _pragma_str(conn: sqlite3.Connection, name: str) -> str:
|
||||
row = conn.execute(f"PRAGMA {name}").fetchone()
|
||||
return str(row[0]) if row else ""
|
||||
|
||||
|
||||
def _migration_names() -> dict[int, str]:
|
||||
"""Discover known migration files and map version -> stem name.
|
||||
|
||||
The migrations table only stores ``version`` and ``applied_at``;
|
||||
the human-readable name lives in the filename
|
||||
(``012_templates.sql`` -> ``012_templates``). Walking the same
|
||||
discovery routine the migrator uses keeps this in sync.
|
||||
"""
|
||||
return {version: path.stem for version, path in _discover()}
|
||||
|
||||
|
||||
def _applied_migrations(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
"""Return all rows from the migrations table joined with the
|
||||
on-disk migration name. Empty list if the table doesn't exist
|
||||
(pre-migration DB)."""
|
||||
table = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';"
|
||||
).fetchone()
|
||||
if not table:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT version, applied_at FROM migrations ORDER BY version ASC"
|
||||
).fetchall()
|
||||
names = _migration_names()
|
||||
return [
|
||||
{
|
||||
"version": int(r[0]),
|
||||
"name": names.get(int(r[0]), f"migration_{int(r[0]):03d}"),
|
||||
"applied_at": str(r[1]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def gather_info(
|
||||
conn: sqlite3.Connection, db_path: Path, *, source: str = "unknown"
|
||||
) -> dict[str, Any]:
|
||||
"""Return a read-only snapshot of the DB suitable for the SPA card.
|
||||
|
||||
``source`` is passed through verbatim. The resolver in
|
||||
``engine.db.connection`` produces one of:
|
||||
``"WP_DB_PATH" | "COMFYUI_USER_DIR" | "user" | "global" | "root"``.
|
||||
``"unknown"`` is reserved for callers that don't have a real source
|
||||
(e.g. engine unit tests that build a connection directly)."""
|
||||
counts: dict[str, int] = {}
|
||||
for ui_name, type_value in _KIND_TYPES.items():
|
||||
counts[ui_name] = _count_modules_by_type(conn, type_value)
|
||||
for ui_name, table_name in _TABLE_COUNTS.items():
|
||||
counts[ui_name] = _count_table(conn, table_name)
|
||||
|
||||
try:
|
||||
stat = db_path.stat()
|
||||
size_bytes = stat.st_size
|
||||
mtime_iso = _iso(stat.st_mtime)
|
||||
except OSError:
|
||||
size_bytes = 0
|
||||
mtime_iso = ""
|
||||
|
||||
return {
|
||||
"path": str(db_path),
|
||||
"source": source,
|
||||
"size_bytes": size_bytes,
|
||||
"mtime_iso": mtime_iso,
|
||||
"counts": counts,
|
||||
"migration": {
|
||||
"current_version": current_version(conn),
|
||||
"applied": _applied_migrations(conn),
|
||||
},
|
||||
"pragma": {
|
||||
"journal_mode": _pragma_str(conn, "journal_mode"),
|
||||
"foreign_keys": _pragma_int(conn, "foreign_keys"),
|
||||
"page_size": _pragma_int(conn, "page_size"),
|
||||
"page_count": _pragma_int(conn, "page_count"),
|
||||
"freelist_count": _pragma_int(conn, "freelist_count"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _db_filename(conn: sqlite3.Connection) -> str | None:
|
||||
"""Return the on-disk filename for the main DB attached to this
|
||||
connection, or None if it's an in-memory / un-attached connection.
|
||||
|
||||
Uses ``PRAGMA database_list`` which returns one row per attached
|
||||
DB; the ``main`` one is what we care about. The third column is the
|
||||
absolute file path (empty string for ``:memory:``)."""
|
||||
try:
|
||||
for row in conn.execute("PRAGMA database_list"):
|
||||
# row order: seq, name, file
|
||||
if row[1] == "main":
|
||||
fname = row[2]
|
||||
return fname if fname else None
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _file_size(conn: sqlite3.Connection) -> int:
|
||||
"""Return current DB file size from the filesystem. Falls back to
|
||||
page_size * page_count (logical size) when the connection is
|
||||
in-memory or the file can't be stat'd."""
|
||||
fname = _db_filename(conn)
|
||||
if fname:
|
||||
try:
|
||||
return Path(fname).stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
page_size = _pragma_int(conn, "page_size")
|
||||
page_count = _pragma_int(conn, "page_count")
|
||||
return page_size * page_count
|
||||
|
||||
|
||||
def vacuum(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Run VACUUM. Returns {ok, op, duration_ms, bytes_reclaimed}.
|
||||
|
||||
bytes_reclaimed is the on-disk size delta of the main DB file. In
|
||||
WAL mode pending writes live in the ``-wal`` sidecar until a
|
||||
checkpoint flushes them into the main DB, so we issue a TRUNCATE
|
||||
checkpoint BOTH before VACUUM (so ``size_before`` reflects the real
|
||||
occupied size including the freelist we're about to reclaim) and
|
||||
after VACUUM (so ``size_after`` reflects the post-vacuum shrinkage
|
||||
immediately rather than waiting for the next automatic checkpoint).
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
# Pre-checkpoint: flush any pending WAL writes into the main DB
|
||||
# file so size_before captures the true on-disk footprint.
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
size_before = _file_size(conn)
|
||||
conn.execute("VACUUM")
|
||||
conn.commit()
|
||||
# Post-checkpoint: VACUUM itself writes via the WAL; flush again
|
||||
# so the shrinkage is reflected on the main DB file right now.
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
except sqlite3.OperationalError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "vacuum",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"error": str(e),
|
||||
}
|
||||
size_after = _file_size(conn)
|
||||
return {
|
||||
"ok": True,
|
||||
"op": "vacuum",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"bytes_reclaimed": max(0, size_before - size_after),
|
||||
}
|
||||
|
||||
|
||||
def integrity_check(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Run PRAGMA integrity_check. ``ok=True`` iff output is exactly ['ok']."""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
rows = conn.execute("PRAGMA integrity_check").fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "integrity",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"output": [],
|
||||
"error": str(e),
|
||||
}
|
||||
output = [str(r[0]) for r in rows]
|
||||
is_ok = output == ["ok"]
|
||||
return {
|
||||
"ok": is_ok,
|
||||
"op": "integrity",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"output": output,
|
||||
}
|
||||
|
||||
|
||||
def analyze(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Run ANALYZE to refresh the query planner's statistics tables."""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
conn.execute("ANALYZE")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "analyze",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"error": str(e),
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"op": "analyze",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
}
|
||||
|
||||
|
||||
def run_migrations(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Force-run pending migrations. Returns the list of versions applied
|
||||
(empty when the DB was already up to date)."""
|
||||
from engine.db.migrations import migrate # local import to keep top-level clean
|
||||
|
||||
before = current_version(conn)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
migrate(conn)
|
||||
except Exception as e: # noqa: BLE001 — surface to UI as a structured failure
|
||||
return {
|
||||
"ok": False,
|
||||
"op": "migrate",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"error": str(e),
|
||||
"applied": [],
|
||||
}
|
||||
after = current_version(conn)
|
||||
applied = list(range(before + 1, after + 1)) if after > before else []
|
||||
return {
|
||||
"ok": True,
|
||||
"op": "migrate",
|
||||
"duration_ms": int((time.monotonic() - t0) * 1000),
|
||||
"applied": applied,
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Lazy-read routine — the ONE migration runtime.
|
||||
|
||||
See community-web spec
|
||||
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
|
||||
2026-06-05-schema-versioning-migration-design.md §4 Flow 5. Every row
|
||||
read (loadModule / loadBundle) goes through this function; the engine
|
||||
startup bulk runner also calls it for each stale row.
|
||||
|
||||
Six branches:
|
||||
1. No-op (row at CURRENT, original NULL or original.sv ≤ CURRENT).
|
||||
2. NULL-drop optimization (row.sv == CURRENT, original.sv == CURRENT).
|
||||
3. Recovery exact catch-up (original.sv ≤ CURRENT, strict-validate
|
||||
succeeds): promote original into payload, NULL original column.
|
||||
4. Still-drifted re-project (original.sv > CURRENT): tolerantly strip.
|
||||
5. Deferred strict-validate failure: mark row 'broken'.
|
||||
6. Standard forward chain (row.sv < CURRENT, no recovery applicable).
|
||||
|
||||
CRITICAL bug-fix from spec §4: the write-back gate is
|
||||
'base_v < current OR base IS original', NOT just 'base_v < current'.
|
||||
Otherwise the recovery exact-catch-up case (base_v == current AND base
|
||||
IS original) silently no-ops and the row stays drifted.
|
||||
|
||||
The three dependency-injected dicts:
|
||||
migrators[(kind, v)] → (payload, ctx) -> next_payload
|
||||
validators[(kind, v, subtype)] → payload -> bool
|
||||
tolerant_strip[(kind, target_v, sub)] → payload -> stripped_payload
|
||||
|
||||
Subtype lookup: for modules, derived from payload["type"]; for bundles, None.
|
||||
original_payload_json is INERT — only the initial tolerant install + this
|
||||
NULL-drop touch it. schema_migrated_at is SEPARATE from updated_at
|
||||
(structural migration is not a user edit).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso
|
||||
|
||||
Migrators = dict[tuple[str, int], Callable[[dict[str, Any], dict], dict]]
|
||||
Validators = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], bool]]
|
||||
TolerantStrip = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], dict]]
|
||||
|
||||
|
||||
def lazy_migrate_row(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kind: str,
|
||||
row_id: str,
|
||||
current_version: int,
|
||||
migrators: Migrators,
|
||||
validators: Validators,
|
||||
tolerant_strip: TolerantStrip | None = None,
|
||||
) -> None:
|
||||
"""Apply lazy migration to a single row.
|
||||
|
||||
Idempotent: if row is already at CURRENT and original is NULL or
|
||||
matches CURRENT, returns without writing (or fires NULL-drop only).
|
||||
"""
|
||||
table = "modules" if kind == "module" else "bundles"
|
||||
row = conn.execute(f"SELECT * FROM {table} WHERE id=?", (row_id,)).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
|
||||
sv = row["schema_version"]
|
||||
original_raw = row["original_payload_json"]
|
||||
original = json.loads(original_raw) if original_raw else None
|
||||
# Narrow original_v to int so the rest of the function's `original is not None`
|
||||
# checks correctly imply an int. A malformed original (no schema_version, or
|
||||
# non-int) is demoted to the no-original branch — the row's payload column is
|
||||
# the only thing we trust.
|
||||
original_v: int | None = None
|
||||
if original is not None:
|
||||
raw_v = original.get("schema_version")
|
||||
if isinstance(raw_v, int):
|
||||
original_v = raw_v
|
||||
else:
|
||||
original = None
|
||||
payload = json.loads(row["payload"])
|
||||
subtype = payload.get("type") if kind == "module" else None
|
||||
|
||||
# Branch 1: no-op or NULL-drop
|
||||
if sv == current_version and (
|
||||
original is None or (original_v is not None and original_v <= current_version)
|
||||
):
|
||||
if original is not None and original_v == sv:
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET original_payload_json=NULL, "
|
||||
f"tolerant_drift_status='none' WHERE id=?",
|
||||
(row_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
|
||||
# Decide base
|
||||
base_is_original = False
|
||||
base_v: int
|
||||
if original is not None and original_v is not None and original_v > sv:
|
||||
if original_v <= current_version:
|
||||
# Recovery candidate — deferred strict-validate FIRST
|
||||
strict = validators.get((kind, original_v, subtype))
|
||||
if strict is not None and not strict(original):
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET tolerant_drift_status='broken', "
|
||||
f"schema_migrated_at=? WHERE id=?",
|
||||
(now_iso(), row_id),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
base = original
|
||||
base_v = original_v
|
||||
base_is_original = True
|
||||
else:
|
||||
# Still drifted; tolerantly strip original down to current
|
||||
strip = (tolerant_strip or {}).get((kind, current_version, subtype))
|
||||
if strip is None:
|
||||
return
|
||||
projected = strip(original)
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET payload=?, schema_version=?, "
|
||||
f"tolerant_drift_status='pending', schema_migrated_at=? WHERE id=?",
|
||||
(
|
||||
json.dumps(projected),
|
||||
current_version,
|
||||
now_iso(),
|
||||
row_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return
|
||||
else:
|
||||
base = payload
|
||||
base_v = sv
|
||||
|
||||
# Write-back gate (THE BUG FIX): write when base_v < current OR base IS original.
|
||||
# The recovery exact-catch-up case has zero chain steps (base_v == current_version)
|
||||
# but still needs the write to promote original into payload + NULL the original
|
||||
# column. Gating on 'base_v < current_version' alone silently no-ops the recovery.
|
||||
needs_write = (base_v < current_version) or base_is_original
|
||||
if not needs_write:
|
||||
return
|
||||
|
||||
new_payload = base
|
||||
for v in range(base_v, current_version):
|
||||
migrator = migrators.get((kind, v))
|
||||
if migrator is None:
|
||||
return
|
||||
new_payload = migrator(new_payload, {})
|
||||
|
||||
promote_from_original = base_is_original
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET payload=?, schema_version=?, schema_migrated_at=?, "
|
||||
f"original_payload_json=?, tolerant_drift_status=? WHERE id=?",
|
||||
(
|
||||
json.dumps(new_payload),
|
||||
current_version,
|
||||
now_iso(),
|
||||
None if promote_from_original else row["original_payload_json"],
|
||||
"none" if promote_from_original else row["tolerant_drift_status"],
|
||||
row_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
+107
-9
@@ -14,6 +14,7 @@ import graph.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -26,6 +27,24 @@ _SQL_DIR = Path(__file__).parent / "migrations_sql"
|
||||
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
|
||||
|
||||
|
||||
class SchemaAheadError(RuntimeError):
|
||||
"""The database was migrated by a build this one cannot safely read.
|
||||
|
||||
Raised instead of letting the runner's "skip anything already applied"
|
||||
rule quietly hand a foreign schema to code that expects its own.
|
||||
"""
|
||||
|
||||
|
||||
def _checksum(path: Path) -> str:
|
||||
"""sha256 of a migration file's bytes.
|
||||
|
||||
Bytes, not parsed content: a `.py` migration's behaviour lives in code the
|
||||
runner `exec`s, so any edit at all is a different migration regardless of
|
||||
whether the SQL it emits looks the same.
|
||||
"""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
@@ -75,8 +94,93 @@ def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
|
||||
return up # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def head_version() -> int:
|
||||
"""Highest migration version this build ships."""
|
||||
pairs = _discover()
|
||||
return pairs[-1][0] if pairs else 0
|
||||
|
||||
|
||||
def _has_checksum_column(conn: sqlite3.Connection) -> bool:
|
||||
cur = conn.execute("PRAGMA table_info(migrations);")
|
||||
return any(row[1] == "checksum" for row in cur.fetchall())
|
||||
|
||||
|
||||
def _record(conn: sqlite3.Connection, version: int, mig_file: Path) -> None:
|
||||
"""Insert the applied-migration row, with a checksum when we can store one.
|
||||
|
||||
Migration 017 is what adds the column, so on a fresh database every
|
||||
migration before it inserts without one. Those rows stay NULL forever —
|
||||
see the 017 docblock for why backfilling them would defeat the check.
|
||||
"""
|
||||
if _has_checksum_column(conn):
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at, checksum) VALUES(?, ?, ?);",
|
||||
(version, now_iso(), _checksum(mig_file)),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def assert_schema_compatible(conn: sqlite3.Connection) -> None:
|
||||
"""Refuse to touch a database some other build's migrations shaped.
|
||||
|
||||
Two distinct failures, both silent before this existed:
|
||||
|
||||
1. The database is AHEAD — migrated by a newer build. Every migration is
|
||||
`<= MAX(version)`, so the runner does nothing and the caller reads a
|
||||
schema it has never seen.
|
||||
2. The database is at the SAME version but a DIFFERENT shape, because a
|
||||
migration file changed after it had already run somewhere. Version
|
||||
numbers match, so nothing compares anything.
|
||||
|
||||
Both are cumulative: each subsequent write goes through code whose
|
||||
assumptions about the row shape are wrong. Raising is recoverable;
|
||||
continuing is not.
|
||||
"""
|
||||
if not _migrations_table_exists(conn):
|
||||
return
|
||||
|
||||
applied = current_version(conn)
|
||||
head = head_version()
|
||||
if applied > head:
|
||||
raise SchemaAheadError(
|
||||
f"This database was migrated to version {applied}, but this build only "
|
||||
f"knows up to {head}. It was almost certainly written by a newer "
|
||||
f"version of Wildcard Pipeline. Reading it here could corrupt your "
|
||||
f"library — install the newer version again, or restore a backup."
|
||||
)
|
||||
|
||||
if not _has_checksum_column(conn):
|
||||
return
|
||||
|
||||
known = dict(_discover())
|
||||
rows = conn.execute(
|
||||
"SELECT version, checksum FROM migrations WHERE checksum IS NOT NULL;"
|
||||
).fetchall()
|
||||
for version, checksum in rows:
|
||||
mig_file = known.get(int(version))
|
||||
# Unknown version is the `applied > head` case, already handled above.
|
||||
if mig_file is None:
|
||||
continue
|
||||
if _checksum(mig_file) != checksum:
|
||||
raise SchemaAheadError(
|
||||
f"Migration {int(version):03d} was applied by a build whose copy of "
|
||||
f"it differs from this one's. The version numbers match but the "
|
||||
f"schemas may not, so this build cannot safely read your library. "
|
||||
f"Restore a backup taken before that build ran."
|
||||
)
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Apply all pending migrations. Safe to call repeatedly."""
|
||||
"""Apply all pending migrations. Safe to call repeatedly.
|
||||
|
||||
Guards first: applying this build's migrations on top of a schema another
|
||||
build shaped is how a mismatch becomes permanent.
|
||||
"""
|
||||
assert_schema_compatible(conn)
|
||||
applied = current_version(conn)
|
||||
for version, mig_file in _discover():
|
||||
if version <= applied:
|
||||
@@ -85,10 +189,7 @@ def migrate(conn: sqlite3.Connection) -> None:
|
||||
up = _load_py_migration(mig_file)
|
||||
with conn:
|
||||
up(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
_record(conn, version, mig_file)
|
||||
else:
|
||||
sql = mig_file.read_text(encoding="utf-8")
|
||||
with conn:
|
||||
@@ -96,7 +197,4 @@ def migrate(conn: sqlite3.Connection) -> None:
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
conn.execute(stmt)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
_record(conn, version, mig_file)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Migration 013 — community install origin columns.
|
||||
|
||||
Adds ``community_post_slug`` and ``community_version_number`` to both
|
||||
``modules`` and ``bundles``. Stamped at install time by the host-bridge
|
||||
``install()`` path (via the embed's ``origin`` opt) so the SPA can:
|
||||
|
||||
- Render an "installed from community" pill on origin-stamped rows.
|
||||
- Run an update-availability check that compares
|
||||
``community_version_number`` against the post's
|
||||
``latest_version_number`` in the community API.
|
||||
|
||||
Both columns are nullable on purpose — locally-authored rows (Create
|
||||
flow, Import-from-file, starter set) leave them NULL forever.
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
|
||||
the migration against a DB that already has the columns is a no-op.
|
||||
The migration runner's existing version table guards against re-runs
|
||||
in production; this guard exists for tests that explicitly rewind the
|
||||
``migrations`` table to exercise the migration on a populated DB
|
||||
(see ``tests/engine/db/test_migration_010.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
if not _has_column(conn, "modules", "community_post_slug"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN community_post_slug TEXT;")
|
||||
if not _has_column(conn, "modules", "community_version_number"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN community_version_number INTEGER;")
|
||||
if not _has_column(conn, "bundles", "community_post_slug"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN community_post_slug TEXT;")
|
||||
if not _has_column(conn, "bundles", "community_version_number"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN community_version_number INTEGER;")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_modules_community_slug "
|
||||
"ON modules(community_post_slug);"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_bundles_community_slug "
|
||||
"ON bundles(community_post_slug);"
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Migration 014: payload schema_version + verbatim-local mirror columns.
|
||||
|
||||
See the community-web design doc at
|
||||
docs/superpowers/specs/2026-06-05-schema-versioning-migration-design.md
|
||||
(in the community-web repo) sections 4 + 5 for the design. The columns:
|
||||
|
||||
- schema_version (INTEGER NOT NULL DEFAULT 1): the payload's shape
|
||||
version. ``1`` is the implicit baseline — every row that existed
|
||||
before this migration was authored against shape v1, so the DEFAULT
|
||||
doubles as the backfill.
|
||||
- original_payload_json (TEXT, nullable): verbatim future-shaped
|
||||
producer bytes preserved through tolerant installs. NULL means no
|
||||
preservation was needed (row was installed at-or-below CURRENT, i.e.
|
||||
no future-shape tolerance was applied).
|
||||
- tolerant_drift_status (TEXT NOT NULL DEFAULT 'none'): enum tracking
|
||||
the drift state of the row's payload vs the catalog. ``'none'`` = no
|
||||
drift; ``'pending'`` = drifted (tolerant-installed from a future
|
||||
shape and not yet reconciled); ``'broken'`` = deferred strict-validate
|
||||
failed.
|
||||
- schema_migrated_at (TEXT, nullable): timestamp of the last structural
|
||||
migration on the row. Intentionally SEPARATE from ``updated_at`` —
|
||||
structural migration is the engine moving the payload between shape
|
||||
versions, not a user edit, so it must not dirty user-edit state.
|
||||
|
||||
Backfill: every pre-existing row stamps ``schema_version=1`` and
|
||||
``tolerant_drift_status='none'`` implicitly via the DEFAULT on the
|
||||
NOT NULL columns — SQLite materializes the default into existing rows
|
||||
when ADD COLUMN runs.
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
|
||||
the migration against a DB that already has the columns is a no-op.
|
||||
The migration runner's existing version table guards against re-runs
|
||||
in production; this guard exists for tests that explicitly rewind the
|
||||
``migrations`` table to exercise the migration on a populated DB
|
||||
(see ``tests/engine/db/test_migration_010.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
# modules
|
||||
if not _has_column(conn, "modules", "schema_version"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
||||
)
|
||||
if not _has_column(conn, "modules", "original_payload_json"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN original_payload_json TEXT;")
|
||||
if not _has_column(conn, "modules", "tolerant_drift_status"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN tolerant_drift_status "
|
||||
"TEXT NOT NULL DEFAULT 'none';"
|
||||
)
|
||||
if not _has_column(conn, "modules", "schema_migrated_at"):
|
||||
conn.execute("ALTER TABLE modules ADD COLUMN schema_migrated_at TEXT;")
|
||||
|
||||
# bundles
|
||||
if not _has_column(conn, "bundles", "schema_version"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "original_payload_json"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN original_payload_json TEXT;")
|
||||
if not _has_column(conn, "bundles", "tolerant_drift_status"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN tolerant_drift_status "
|
||||
"TEXT NOT NULL DEFAULT 'none';"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "schema_migrated_at"):
|
||||
conn.execute("ALTER TABLE bundles ADD COLUMN schema_migrated_at TEXT;")
|
||||
|
||||
# Bulk run: at the time 014 ships, CURRENT_SCHEMA_VERSION is still 1, so
|
||||
# every existing row is already at CURRENT and the bulk is a no-op pass.
|
||||
# We invoke it anyway to exercise + validate the wiring on first run.
|
||||
# FUTURE: when a bump migration 015 ships, it ALSO calls run_bulk_lazy
|
||||
# with the new CURRENT and the bump's migrators.
|
||||
run_bulk_lazy(conn, current_version=1, migrators={}, validators={}, tolerant_strip={})
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Migration 015 -- content_rating column on library rows.
|
||||
|
||||
Adds ``content_rating`` (TEXT, default 'safe') to both ``modules`` and
|
||||
``bundles``. Stamped by:
|
||||
|
||||
- The IdentityCard's NSFW toggle in every editor (user-controlled
|
||||
flag the author sets on their own row).
|
||||
- The community install bridge when ``opts.origin.content_rating`` is
|
||||
supplied -- NSFW community posts auto-stamp the row at install
|
||||
time, so the SPA can render the ``18+`` pill without an extra
|
||||
fetch.
|
||||
|
||||
Default 'safe' means rows that pre-date this migration (every
|
||||
locally-authored row prior to v2.3) end up marked safe. Toggle to
|
||||
'nsfw' if needed. No CHECK constraint at the DB level (SQLite ALTER
|
||||
TABLE does not support adding CHECK); validation happens at the
|
||||
application layer (``set_content_rating``).
|
||||
|
||||
Idempotent: each ALTER is gated on ``PRAGMA table_info``. The
|
||||
migration runner's version table is the primary re-run guard; the
|
||||
column check exists for test fixtures that rewind ``migrations``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
with conn:
|
||||
if not _has_column(conn, "modules", "content_rating"):
|
||||
conn.execute(
|
||||
"ALTER TABLE modules ADD COLUMN content_rating TEXT "
|
||||
"NOT NULL DEFAULT 'safe';"
|
||||
)
|
||||
if not _has_column(conn, "bundles", "content_rating"):
|
||||
conn.execute(
|
||||
"ALTER TABLE bundles ADD COLUMN content_rating TEXT "
|
||||
"NOT NULL DEFAULT 'safe';"
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Migration 016: wildcard multi-sub-category v2 (SP1).
|
||||
|
||||
Marches every stale row from schema_version 1 -> 2 by eager iteration of
|
||||
the lazy-read routine (see `_014_helpers.run_bulk_lazy`), using the SP1
|
||||
per-row migrators. The transform:
|
||||
|
||||
* option `sub_category: "x"` -> `sub_categories: ["x"]`;
|
||||
* sub-category names slugified (whitespace / grammar chars -> `_`),
|
||||
reserved words suffixed, de-duped, cascaded to options + groups;
|
||||
* nested `@{uuid:a,b,null}` refs -> `@{uuid:a or b!null}`.
|
||||
|
||||
See engine/migrations/v1_to_v2.py for the shared transform + the envelope
|
||||
mirror used by import/export, and CLAUDE.md "Schema versioning".
|
||||
|
||||
Idempotent: rows already at schema_version 2 are not stale, so a re-run
|
||||
(or a run on a fresh DB with no v1 rows) is a no-op. validators /
|
||||
tolerant_strip are empty — the forward 1->2 chain needs neither (there is
|
||||
no future-shaped `original_payload_json` to recover at this bump).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
|
||||
from engine.migrations.v1_to_v2 import (
|
||||
migrate_bundle_v1_to_v2,
|
||||
migrate_module_v1_to_v2,
|
||||
)
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
run_bulk_lazy(
|
||||
conn,
|
||||
current_version=2,
|
||||
migrators={
|
||||
("module", 1): migrate_module_v1_to_v2,
|
||||
("bundle", 1): migrate_bundle_v1_to_v2,
|
||||
},
|
||||
validators={},
|
||||
tolerant_strip={},
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Migration 017 — record WHICH migration ran, not just how many.
|
||||
|
||||
``migrations`` tracked only ``version``, and the runner skips anything
|
||||
``<= MAX(version)``. That makes an integer the entire identity of a
|
||||
schema, which it is not:
|
||||
|
||||
- A build whose head is 015 opened against a DB migrated to 016 skips
|
||||
every migration, finds nothing to do, and reads a schema it has never
|
||||
seen. Additive columns survive; a shape change does not.
|
||||
- Worse, two builds can both call something "016" and mean different
|
||||
things — a reworked migration, a hand-edited file, a branch that never
|
||||
shipped. The version matches, so nothing anywhere notices.
|
||||
|
||||
Both are silent, and both corrupt cumulatively: every subsequent write
|
||||
goes through code whose assumptions about the row shape are wrong.
|
||||
|
||||
``checksum`` fixes it by storing the sha256 of the migration file that
|
||||
actually ran. ``assert_schema_compatible`` then compares applied
|
||||
checksums against the files this build ships, so a divergence is a
|
||||
loud, recoverable error instead of silence.
|
||||
|
||||
Nullable on purpose: rows applied before this migration existed have no
|
||||
recorded checksum and cannot retroactively grow one. The guard skips
|
||||
them rather than guessing — a NULL means "unknown", never "mismatched".
|
||||
|
||||
Idempotent via ``PRAGMA table_info`` so tests that rewind the
|
||||
``migrations`` table can re-run it against a populated DB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
if not _has_column(conn, "migrations", "checksum"):
|
||||
conn.execute("ALTER TABLE migrations ADD COLUMN checksum TEXT;")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Helpers for migration 014 — the bulk runner used at startup + at
|
||||
future payload-bump migrations.
|
||||
|
||||
See community-web spec
|
||||
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
|
||||
2026-06-05-schema-versioning-migration-design.md §4 Flow 4: bulk is
|
||||
EAGER ITERATION of the lazy-read routine, NOT a parallel forward-chain
|
||||
implementation. This eliminates the transient-wrong window where bulk
|
||||
could write fabricated defaults over recoverable data preserved in
|
||||
original_payload_json.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from engine.db.lazy_migrate import lazy_migrate_row
|
||||
|
||||
STALE_PREDICATE = (
|
||||
"schema_version < ? "
|
||||
"OR (original_payload_json IS NOT NULL AND "
|
||||
" json_extract(original_payload_json, '$.schema_version') > schema_version)"
|
||||
)
|
||||
|
||||
|
||||
def run_bulk_lazy(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
current_version: int,
|
||||
migrators,
|
||||
validators,
|
||||
tolerant_strip,
|
||||
) -> int:
|
||||
"""Eager iteration of lazy_migrate_row over every stale row.
|
||||
|
||||
The stale predicate catches BOTH 'sv < current' AND 'tolerantly-
|
||||
installed row whose original now outranks the projection'. The
|
||||
second clause handles rows where the extension has caught up after
|
||||
a previous tolerant install.
|
||||
|
||||
Returns count of rows that failed to migrate (caller surfaces a
|
||||
dashboard banner when > 0).
|
||||
"""
|
||||
failed = 0
|
||||
for table, kind in [("modules", "module"), ("bundles", "bundle")]:
|
||||
ids = [
|
||||
row["id"]
|
||||
for row in conn.execute(
|
||||
f"SELECT id FROM {table} WHERE {STALE_PREDICATE}",
|
||||
(current_version,),
|
||||
).fetchall()
|
||||
]
|
||||
for row_id in ids:
|
||||
try:
|
||||
lazy_migrate_row(
|
||||
conn,
|
||||
kind=kind,
|
||||
row_id=row_id,
|
||||
current_version=current_version,
|
||||
migrators=migrators,
|
||||
validators=validators,
|
||||
tolerant_strip=tolerant_strip,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — defensive sweep
|
||||
failed += 1
|
||||
continue
|
||||
# After lazy_migrate_row: if the row's schema_version is still
|
||||
# below current_version, count as failed.
|
||||
after = conn.execute(
|
||||
f"SELECT schema_version FROM {table} WHERE id=?", (row_id,)
|
||||
).fetchone()
|
||||
if after and after["schema_version"] < current_version:
|
||||
failed += 1
|
||||
return failed
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Execute deferred DB file moves at plugin load.
|
||||
|
||||
The SPA Settings UI lets users change the DB location. The actual file
|
||||
operation runs here, on next ComfyUI start, BEFORE any DB connection
|
||||
opens. Errors are logged and never crash the host.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from engine.db.config import clear_pending_move, load
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def execute_pending_move() -> None:
|
||||
"""If the sidecar has a pending_move, run it and clear the entry.
|
||||
|
||||
Idempotent if the move already happened (source missing, dest present).
|
||||
Failures (source missing without dest, permission errors) are logged;
|
||||
sidecar is left intact so the UI can show the pending state on next
|
||||
page load.
|
||||
"""
|
||||
cfg = load()
|
||||
pm = cfg.get("pending_move")
|
||||
if not pm:
|
||||
return
|
||||
src = Path(pm["from"])
|
||||
dst = Path(pm["to"])
|
||||
mode = pm["mode"]
|
||||
|
||||
# Resume: previous run completed the move but failed to clear sidecar.
|
||||
if not src.exists() and dst.exists():
|
||||
logger.info("wp_db_pending_move: already-done (src missing, dst present), clearing")
|
||||
clear_pending_move()
|
||||
return
|
||||
|
||||
if not src.exists():
|
||||
logger.error("wp_db_pending_move: source %s missing, skipping", src)
|
||||
return
|
||||
|
||||
try:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mode == "copy":
|
||||
shutil.copy2(src, dst)
|
||||
else: # move
|
||||
shutil.move(str(src), str(dst))
|
||||
except OSError as e:
|
||||
logger.exception("wp_db_pending_move: %s failed: %s", mode, e)
|
||||
return
|
||||
|
||||
clear_pending_move()
|
||||
logger.info("wp_db_pending_move: %s %s -> %s OK", mode, src, dst)
|
||||
+158
-18
@@ -19,6 +19,21 @@ from engine.modules.snapshot import payload_hash
|
||||
_VALID_TYPES: frozenset[str] = frozenset({
|
||||
"wildcard", "fixed_values", "combine", "derivation", "constraint",
|
||||
})
|
||||
|
||||
# Free-text adult-content flag on every library row (migration 015).
|
||||
# Stamped by the IdentityCard's NSFW toggle or auto-stamped at install
|
||||
# time when a community post carried `content_rating='nsfw'`. No DB-level
|
||||
# CHECK constraint (SQLite ALTER limit); validate at the repository.
|
||||
_VALID_CONTENT_RATINGS: frozenset[str] = frozenset({"safe", "nsfw"})
|
||||
|
||||
|
||||
def _validate_content_rating(rating: str) -> str:
|
||||
if rating not in _VALID_CONTENT_RATINGS:
|
||||
raise ValueError(
|
||||
f"content_rating must be one of {sorted(_VALID_CONTENT_RATINGS)}, "
|
||||
f"got {rating!r}"
|
||||
)
|
||||
return rating
|
||||
# Module ids are 8-hex short uuids — same shape the tokenizer's
|
||||
# `@{8hex}` ref token captures and the engine catalog keys by. Slug
|
||||
# prefixes (e.g. `wc_outfit_a1b2c3d4`) were removed in migration 004
|
||||
@@ -55,6 +70,24 @@ class ModuleNotFound(LookupError):
|
||||
|
||||
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
payload = json.loads(row["payload"])
|
||||
# Community origin columns (migration 013) — NULL for any row that
|
||||
# didn't come from a community install. Surfaced verbatim so the
|
||||
# SPA can render an "installed from community" badge + drive
|
||||
# update-available checks.
|
||||
try:
|
||||
community_post_slug = row["community_post_slug"]
|
||||
community_version_number = row["community_version_number"]
|
||||
except (IndexError, KeyError):
|
||||
# Defensive: a connection opened against a DB that hasn't run
|
||||
# migration 013 yet (e.g. mid-rollout, test fixtures) won't
|
||||
# have these columns. Treat absence as NULL.
|
||||
community_post_slug = None
|
||||
community_version_number = None
|
||||
try:
|
||||
content_rating = row["content_rating"]
|
||||
except (IndexError, KeyError):
|
||||
# Pre-015 fixture: column absent, treat as 'safe'.
|
||||
content_rating = "safe"
|
||||
return {
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
@@ -69,6 +102,9 @@ def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"community_post_slug": community_post_slug,
|
||||
"community_version_number": community_version_number,
|
||||
"content_rating": content_rating,
|
||||
}
|
||||
|
||||
|
||||
@@ -125,13 +161,14 @@ class ModuleRepository:
|
||||
payload: dict[str, Any],
|
||||
is_favorite: bool = False,
|
||||
id: str | None = None,
|
||||
content_rating: str = "safe",
|
||||
) -> dict[str, Any]:
|
||||
"""Insert a new module row.
|
||||
|
||||
``id`` is normally generated; pass an explicit id to import
|
||||
a workflow-resident snapshot back into the library at the
|
||||
SAME uuid (so a freshly-saved row immediately matches the
|
||||
workflow's existing references — no broken `@{uuid}` refs).
|
||||
workflow's existing references -- no broken `@{uuid}` refs).
|
||||
Validates the supplied id is 8-hex to prevent the workflow
|
||||
from injecting arbitrary primary-key shapes.
|
||||
"""
|
||||
@@ -139,6 +176,7 @@ class ModuleRepository:
|
||||
raise ValueError(
|
||||
f"unknown module type {type!r}; expected one of {sorted(_VALID_TYPES)}"
|
||||
)
|
||||
_validate_content_rating(content_rating)
|
||||
if type == "wildcard":
|
||||
payload = self._backfill_option_ids(payload)
|
||||
if id is None:
|
||||
@@ -163,12 +201,13 @@ class ModuleRepository:
|
||||
self._conn.execute(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, snapshot_fingerprint, version, created_at, updated_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
|
||||
"is_favorite, payload, snapshot_fingerprint, version, "
|
||||
"created_at, updated_at, content_rating"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
|
||||
(
|
||||
mid, type, name, description, category_id,
|
||||
json.dumps(tags), int(is_favorite),
|
||||
json.dumps(payload), fp, now, now,
|
||||
json.dumps(payload), fp, now, now, content_rating,
|
||||
),
|
||||
)
|
||||
return self.get(mid)
|
||||
@@ -226,6 +265,7 @@ class ModuleRepository:
|
||||
tags: list[str] | _Unset = _UNSET,
|
||||
payload: dict[str, Any] | _Unset = _UNSET,
|
||||
is_favorite: bool | _Unset = _UNSET,
|
||||
content_rating: str | _Unset = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
existing = self.get(module_id)
|
||||
# Backfill missing option ids when caller writes a fresh wildcard payload.
|
||||
@@ -235,6 +275,8 @@ class ModuleRepository:
|
||||
and isinstance(payload, dict)
|
||||
):
|
||||
payload = self._backfill_option_ids(payload)
|
||||
if not isinstance(content_rating, _Unset):
|
||||
_validate_content_rating(content_rating)
|
||||
new = {
|
||||
"name": existing["name"] if isinstance(name, _Unset) else name,
|
||||
"description": (
|
||||
@@ -248,6 +290,11 @@ class ModuleRepository:
|
||||
"is_favorite": (
|
||||
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
|
||||
),
|
||||
"content_rating": (
|
||||
existing["content_rating"]
|
||||
if isinstance(content_rating, _Unset)
|
||||
else content_rating
|
||||
),
|
||||
}
|
||||
new_payload_hash = payload_hash(new["payload"])
|
||||
new_fp = module_fingerprint({
|
||||
@@ -263,16 +310,44 @@ class ModuleRepository:
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"version = version + 1, updated_at = ?, content_rating = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
new["name"], new["description"], new["category_id"],
|
||||
json.dumps(new["tags"]), int(new["is_favorite"]),
|
||||
json.dumps(new["payload"]), new_fp, now, module_id,
|
||||
json.dumps(new["payload"]), new_fp, now,
|
||||
new["content_rating"], module_id,
|
||||
),
|
||||
)
|
||||
return self.get(module_id)
|
||||
|
||||
def set_community_origin(
|
||||
self, module_id: str, *, post_slug: str, version_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Stamp community origin on an existing row WITHOUT a version
|
||||
bump or fingerprint change.
|
||||
|
||||
Used by the publish-writeback path (host bridge
|
||||
``markPublished``): after a row is published to community, the
|
||||
local row gets linked to the resulting post so the SPA renders
|
||||
the community pill, the update-available checker tracks it, and
|
||||
a future re-publish detects "this is my own post" (version bump
|
||||
vs fork). Origin metadata is NOT runtime-affecting, so unlike
|
||||
``update`` this neither bumps ``version`` nor recomputes the
|
||||
snapshot fingerprint -- a published row stays byte-identical to
|
||||
what consumers already installed.
|
||||
"""
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"community_post_slug = ?, community_version_number = ? "
|
||||
"WHERE id = ?;",
|
||||
(post_slug, version_number, module_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise ModuleNotFound(module_id)
|
||||
return self.get(module_id)
|
||||
|
||||
def delete(self, module_id: str) -> None:
|
||||
with self._conn:
|
||||
cur = self._conn.execute("DELETE FROM modules WHERE id = ?;", (module_id,))
|
||||
@@ -307,7 +382,15 @@ class ModuleRepository:
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
|
||||
# Match the name OR the uuid — searching by id is the fast path for
|
||||
# debugging library linkage (finding the row a workflow's @{id} /
|
||||
# constraint source/target points at). NOCASE so an 8-hex id typed
|
||||
# in any case still matches.
|
||||
clauses.append(
|
||||
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
|
||||
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
|
||||
)
|
||||
params.append(f"%{escaped}%")
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
@@ -481,6 +564,16 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
|
||||
intentionally frozen — the saved hash is the source of truth for
|
||||
insert-time `inserted_at_hash` capture + library-drift detection.
|
||||
"""
|
||||
try:
|
||||
community_post_slug = row["community_post_slug"]
|
||||
community_version_number = row["community_version_number"]
|
||||
except (IndexError, KeyError):
|
||||
community_post_slug = None
|
||||
community_version_number = None
|
||||
try:
|
||||
content_rating = row["content_rating"]
|
||||
except (IndexError, KeyError):
|
||||
content_rating = "safe"
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
@@ -494,6 +587,9 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"community_post_slug": community_post_slug,
|
||||
"community_version_number": community_version_number,
|
||||
"content_rating": content_rating,
|
||||
}
|
||||
|
||||
|
||||
@@ -529,7 +625,9 @@ class BundleRepository:
|
||||
children: list[dict[str, Any]] | None = None,
|
||||
is_favorite: bool = False,
|
||||
id: str | None = None,
|
||||
content_rating: str = "safe",
|
||||
) -> dict[str, Any]:
|
||||
_validate_content_rating(content_rating)
|
||||
if id is None:
|
||||
bid = self._gen_id()
|
||||
else:
|
||||
@@ -541,7 +639,7 @@ class BundleRepository:
|
||||
)
|
||||
bid = id
|
||||
children_blob = list(children) if children else []
|
||||
# `payload_hash` is computed off the children blob — the only
|
||||
# `payload_hash` is computed off the children blob -- the only
|
||||
# field whose change should signal "library has a newer
|
||||
# version" to inserted instances.
|
||||
ph = payload_hash({"children": children_blob})
|
||||
@@ -551,12 +649,12 @@ class BundleRepository:
|
||||
"INSERT INTO bundles("
|
||||
"id, name, description, color, category_id, tags, "
|
||||
"is_favorite, children, payload_hash, version, "
|
||||
"created_at, updated_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
|
||||
"created_at, updated_at, content_rating"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
|
||||
(
|
||||
bid, name, description, color, category_id,
|
||||
json.dumps(tags or []), int(is_favorite),
|
||||
json.dumps(children_blob), ph, now, now,
|
||||
json.dumps(children_blob), ph, now, now, content_rating,
|
||||
),
|
||||
)
|
||||
return self.get(bid)
|
||||
@@ -580,8 +678,11 @@ class BundleRepository:
|
||||
tags: list[str] | _Unset = _UNSET,
|
||||
children: list[dict[str, Any]] | _Unset = _UNSET,
|
||||
is_favorite: bool | _Unset = _UNSET,
|
||||
content_rating: str | _Unset = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
existing = self.get(bundle_id)
|
||||
if not isinstance(content_rating, _Unset):
|
||||
_validate_content_rating(content_rating)
|
||||
new = {
|
||||
"name": existing["name"] if isinstance(name, _Unset) else name,
|
||||
"description": (
|
||||
@@ -598,11 +699,16 @@ class BundleRepository:
|
||||
"is_favorite": (
|
||||
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
|
||||
),
|
||||
"content_rating": (
|
||||
existing["content_rating"]
|
||||
if isinstance(content_rating, _Unset)
|
||||
else content_rating
|
||||
),
|
||||
}
|
||||
# Recompute payload_hash whenever children change. Other field
|
||||
# edits (rename, recolor, retag) don't affect runtime behavior
|
||||
# so they don't bump the hash — inserted instances stay clean
|
||||
# on a pure-cosmetic library update.
|
||||
# edits (rename, recolor, retag, NSFW toggle) don't affect runtime
|
||||
# behavior so they don't bump the hash -- inserted instances stay
|
||||
# clean on a pure-cosmetic library update.
|
||||
ph = (
|
||||
existing["payload_hash"]
|
||||
if isinstance(children, _Unset)
|
||||
@@ -614,17 +720,35 @@ class BundleRepository:
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"version = version + 1, updated_at = ?, content_rating = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
new["name"], new["description"], new["color"],
|
||||
new["category_id"], json.dumps(new["tags"]),
|
||||
int(new["is_favorite"]),
|
||||
json.dumps(new["children"]), ph, now, bundle_id,
|
||||
json.dumps(new["children"]), ph, now,
|
||||
new["content_rating"], bundle_id,
|
||||
),
|
||||
)
|
||||
return self.get(bundle_id)
|
||||
|
||||
def set_community_origin(
|
||||
self, bundle_id: str, *, post_slug: str, version_number: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Stamp community origin on a bundle row WITHOUT a version bump
|
||||
or payload-hash change. See ModuleRepository.set_community_origin
|
||||
for the rationale -- origin metadata is not runtime-affecting."""
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"community_post_slug = ?, community_version_number = ? "
|
||||
"WHERE id = ?;",
|
||||
(post_slug, version_number, bundle_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise BundleNotFound(bundle_id)
|
||||
return self.get(bundle_id)
|
||||
|
||||
def delete(self, bundle_id: str) -> None:
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
@@ -651,7 +775,15 @@ class BundleRepository:
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
|
||||
# Match the name OR the uuid — searching by id is the fast path for
|
||||
# debugging library linkage (finding the row a workflow's @{id} /
|
||||
# constraint source/target points at). NOCASE so an 8-hex id typed
|
||||
# in any case still matches.
|
||||
clauses.append(
|
||||
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
|
||||
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
|
||||
)
|
||||
params.append(f"%{escaped}%")
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
@@ -835,7 +967,15 @@ class TemplateRepository:
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
|
||||
# Match the name OR the uuid — searching by id is the fast path for
|
||||
# debugging library linkage (finding the row a workflow's @{id} /
|
||||
# constraint source/target points at). NOCASE so an 8-hex id typed
|
||||
# in any case still matches.
|
||||
clauses.append(
|
||||
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
|
||||
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
|
||||
)
|
||||
params.append(f"%{escaped}%")
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
|
||||
+119
-35
@@ -154,8 +154,10 @@ def _insert_module(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, snapshot_fingerprint, version, "
|
||||
"created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
"created_at, updated_at, "
|
||||
"community_post_slug, community_version_number, "
|
||||
"content_rating"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
mid, kind, entity["name"], entity.get("description", ""),
|
||||
entity.get("category_id"),
|
||||
@@ -166,6 +168,17 @@ def _insert_module(
|
||||
entity.get("version", 1),
|
||||
entity.get("created_at", now),
|
||||
entity.get("updated_at", now),
|
||||
# Community origin -- only set when the install bridge stamps
|
||||
# them on the entity (see manager/main.ts snapshotLibrary +
|
||||
# the embed's install call). Local Create/Import-from-file
|
||||
# paths leave these NULL so the row stays "locally authored".
|
||||
entity.get("community_post_slug"),
|
||||
entity.get("community_version_number"),
|
||||
# content_rating defaults to 'safe' when the entity carries
|
||||
# no value -- migration 015 stamps the same default. Community
|
||||
# install passes 'nsfw' through via the entity when the post
|
||||
# carries content_rating='nsfw'.
|
||||
entity.get("content_rating", "safe"),
|
||||
),
|
||||
)
|
||||
return mid
|
||||
@@ -193,8 +206,10 @@ def _insert_bundle(
|
||||
"INSERT INTO bundles("
|
||||
"id, name, description, color, category_id, tags, "
|
||||
"is_favorite, children, payload_hash, version, "
|
||||
"created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
"created_at, updated_at, "
|
||||
"community_post_slug, community_version_number, "
|
||||
"content_rating"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
|
||||
(
|
||||
bid, entity["name"], entity.get("description", ""),
|
||||
entity.get("color"),
|
||||
@@ -206,6 +221,11 @@ def _insert_bundle(
|
||||
entity.get("version", 1),
|
||||
entity.get("created_at", now),
|
||||
entity.get("updated_at", now),
|
||||
# Community origin -- see _insert_module comment above.
|
||||
entity.get("community_post_slug"),
|
||||
entity.get("community_version_number"),
|
||||
# content_rating -- see _insert_module comment above.
|
||||
entity.get("content_rating", "safe"),
|
||||
),
|
||||
)
|
||||
return bid
|
||||
@@ -219,50 +239,114 @@ def _update_module(
|
||||
Preserves `mid` and the row's `type` (the kind dispatch already
|
||||
routed us here so type is implied). Bumps `version` and stamps
|
||||
`updated_at` like `ModuleRepository.update`.
|
||||
|
||||
Community origin (migration 013) is rewritten ONLY when the new
|
||||
content explicitly carries it — a community Update-in-place
|
||||
install stamps both fields on the entity, while a vanilla Import
|
||||
tab replace leaves them absent and the row keeps its prior
|
||||
community_* values (or NULL for locally-authored rows).
|
||||
"""
|
||||
_require_entity_fields(kind, "replace", content, ("name",))
|
||||
now = now_iso()
|
||||
fp = _module_fp_for_entity({**content, "type": kind})
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now, mid,
|
||||
),
|
||||
)
|
||||
if "community_post_slug" in content or "community_version_number" in content:
|
||||
# `content_rating` uses COALESCE so missing key preserves the
|
||||
# existing value -- community-update installs that only bump
|
||||
# the version don't accidentally wipe a user-set NSFW flag.
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"community_post_slug = ?, community_version_number = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now,
|
||||
content.get("community_post_slug"),
|
||||
content.get("community_version_number"),
|
||||
content.get("content_rating"),
|
||||
mid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(content.get("payload") or {}),
|
||||
fp, now,
|
||||
content.get("content_rating"),
|
||||
mid,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _update_bundle(
|
||||
conn: sqlite3.Connection, bid: str, content: dict[str, Any],
|
||||
) -> None:
|
||||
"""UPDATE the bundles row identified by `bid` to mirror `content`."""
|
||||
"""UPDATE the bundles row identified by `bid` to mirror `content`.
|
||||
|
||||
See _update_module for the community_* rewrite semantics.
|
||||
"""
|
||||
_require_entity_fields("bundle", "replace", content, ("name",))
|
||||
children_blob = list(content.get("children") or [])
|
||||
ph = payload_hash({"children": children_blob})
|
||||
now = now_iso()
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now, bid,
|
||||
),
|
||||
)
|
||||
if "community_post_slug" in content or "community_version_number" in content:
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"community_post_slug = ?, community_version_number = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now,
|
||||
content.get("community_post_slug"),
|
||||
content.get("community_version_number"),
|
||||
content.get("content_rating"),
|
||||
bid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE bundles SET "
|
||||
"name = ?, description = ?, color = ?, category_id = ?, "
|
||||
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
|
||||
"version = version + 1, updated_at = ?, "
|
||||
"content_rating = COALESCE(?, content_rating) "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
content["name"], content.get("description", ""),
|
||||
content.get("color"),
|
||||
content.get("category_id"),
|
||||
json.dumps(content.get("tags") or []),
|
||||
int(content.get("is_favorite", False)),
|
||||
json.dumps(children_blob), ph, now,
|
||||
content.get("content_rating"),
|
||||
bid,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _module_exists(conn: sqlite3.Connection, mid: str) -> bool:
|
||||
|
||||
@@ -10,11 +10,26 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from engine.migrations.v0_to_v1 import migrate_v0_to_v1
|
||||
from engine.migrations.v1_to_v2 import migrate_v1_to_v2
|
||||
|
||||
CURRENT_SCHEMA_VERSION = 1
|
||||
CURRENT_SCHEMA_VERSION = 2
|
||||
|
||||
# Highest schema version this runtime can natively READ + WRITE — distinct
|
||||
# from CURRENT_SCHEMA_VERSION (the migration-chain head, which stays 2 because
|
||||
# v2->v3/v3->v4 are no-ops). v3 is the SP2b text-grammar bump (shape-identical
|
||||
# to v2) and v4 is the additive `target_select` constraint reach; both are
|
||||
# handled natively, so a payload at CURRENT < v <= MAX_KNOWN installs AS-IS
|
||||
# with no migration. Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
|
||||
# `src/manager/import-export/migrations.ts`.
|
||||
#
|
||||
# MAINTENANCE CONTRACT: bump this whenever the TS `schemaVersionForPayload()`
|
||||
# learns to stamp a new (higher) version — otherwise this commit-side
|
||||
# re-validate will reject the very shapes the runtime just learned to produce.
|
||||
MAX_KNOWN_SCHEMA_VERSION = 4
|
||||
|
||||
_CHAIN = {
|
||||
0: migrate_v0_to_v1,
|
||||
1: migrate_v1_to_v2,
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +47,16 @@ def migrate_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
version = payload.get("schema_version")
|
||||
if not isinstance(version, int):
|
||||
return {"ok": False, "reason": "payload missing schema_version field"}
|
||||
if version > CURRENT_SCHEMA_VERSION:
|
||||
# Reject threshold reads MAX_KNOWN (highest natively-supported version),
|
||||
# NOT CURRENT (the migration-chain head). A payload at
|
||||
# CURRENT < v <= MAX_KNOWN is shape-compatible and handled natively, so it
|
||||
# passes through AS-IS — the while-loop below is bound by CURRENT, so there
|
||||
# is nothing to migrate and schema_version is preserved. Only > MAX_KNOWN
|
||||
# rejects. Mirror of TS `migrateImportEnvelope`.
|
||||
if version > MAX_KNOWN_SCHEMA_VERSION:
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": f"future schema version {version} (current: {CURRENT_SCHEMA_VERSION})",
|
||||
"reason": f"future schema version {version} (max known: {MAX_KNOWN_SCHEMA_VERSION})",
|
||||
}
|
||||
current = {
|
||||
"schema_version": version,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""v1 -> v2 payload migration (SP1 multi-tag + boolean sub-category filter).
|
||||
|
||||
Transforms, all idempotent:
|
||||
* option `sub_category: "x"` -> `sub_categories: ["x"]` (None/absent -> []);
|
||||
* registry + option + tag_group sub-category names slugified to a single
|
||||
safe token (whitespace / boolean-grammar / ref chars -> `_`), reserved
|
||||
words suffixed (`or` -> `or_1`), de-duped — cascaded so an option/group
|
||||
membership tracks its renamed registry entry;
|
||||
* instance `category_filter: [...]` -> a boolean expression string
|
||||
(`" or ".join`) + a separate `exclude_null` flag (the old `"null"`
|
||||
list-entry);
|
||||
* nested `@{uuid#name:a,b,null}` refs -> `@{uuid#name:a or b!null}`
|
||||
(comma list -> `or`, `null` keyword -> trailing `!null` marker).
|
||||
|
||||
Two entry shapes share one core (`_deep_migrate`):
|
||||
* `migrate_v1_to_v2(envelope)` — the import/export multi-row envelope
|
||||
(`wildcards[]`/`bundles[]`/...); registered in the version `_CHAIN`.
|
||||
* `migrate_module_v1_to_v2` / `migrate_bundle_v1_to_v2` — per-row payload
|
||||
migrators for the lazy/bulk DB path (`engine/db/lazy_migrate.py`); the
|
||||
row's `payload` column is the inner module payload (`{options, ...}`,
|
||||
no `type` field — wildcard-ness is detected structurally).
|
||||
|
||||
Mirror: src/manager/import-export/migrations.ts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.syntax.subcat_filter import RESERVED
|
||||
|
||||
# Chars a v2 sub-category name may not contain (mirrors subcat_filter
|
||||
# validate_subcat_name): whitespace + the boolean-grammar + ref delimiters.
|
||||
_BAD = re.compile(r"[\s()!,#:}@{$]+")
|
||||
# Broad ref matcher (v1 OR v2 form) so v1 comma/null lists get rewritten
|
||||
# while v2 refs pass through untouched. Groups: uuid, `#name` seg, `:tail`.
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(#[^:}]*)?(:[^}]*)?\}")
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
return _BAD.sub("_", name).strip("_") or "_"
|
||||
|
||||
|
||||
def _name_map(registry: list[str]) -> dict[str, str]:
|
||||
"""old sub-category name -> sanitized, reserved-safe, unique name."""
|
||||
out: dict[str, str] = {}
|
||||
used: set[str] = set()
|
||||
for raw in registry:
|
||||
s = _slug(raw)
|
||||
if s.lower() in RESERVED:
|
||||
s = f"{s}_1"
|
||||
base, n = s, 1
|
||||
while s in used:
|
||||
n += 1
|
||||
s = f"{base}_{n}"
|
||||
used.add(s)
|
||||
out[raw] = s
|
||||
return out
|
||||
|
||||
|
||||
def _rewrite_ref(m: re.Match[str]) -> str:
|
||||
uuid = m.group(1)
|
||||
name_seg = m.group(2) or ""
|
||||
tail = m.group(3)
|
||||
if tail is None:
|
||||
return m.group(0) # bare `@{uuid}` / `@{uuid#name}` — nothing to do
|
||||
lst = tail[1:] # strip the leading ':'
|
||||
has_comma = "," in lst
|
||||
parts = [x.strip() for x in lst.split(",")]
|
||||
has_null = "null" in parts
|
||||
if not has_comma and not has_null:
|
||||
# Single tag OR an already-v2 boolean expression — leave as-is so
|
||||
# the rewrite is idempotent and never corrupts a v2 ref.
|
||||
return m.group(0)
|
||||
# Slug each tag so a cross-wildcard ref tracks the target's renamed
|
||||
# registry entry (deterministic — same _slug the target applies).
|
||||
tags = [_slug(p) for p in parts if p and p != "null"]
|
||||
expr = " or ".join(tags)
|
||||
out = "@{" + uuid + name_seg
|
||||
if expr:
|
||||
out += ":" + expr
|
||||
if has_null:
|
||||
out += "!null"
|
||||
return out + "}"
|
||||
|
||||
|
||||
def _rewrite_refs(s: str) -> str:
|
||||
return _REF_RE.sub(_rewrite_ref, s)
|
||||
|
||||
|
||||
def _is_wildcard_payload(p: Any) -> bool:
|
||||
"""A wildcard payload is the only shape carrying a sub-category model:
|
||||
an `options` list plus either a `sub_categories` registry or an option
|
||||
bearing the legacy singular `sub_category` field."""
|
||||
if not isinstance(p, dict) or not isinstance(p.get("options"), list):
|
||||
return False
|
||||
if "sub_categories" in p:
|
||||
return True
|
||||
return any(isinstance(o, dict) and "sub_category" in o for o in p["options"])
|
||||
|
||||
|
||||
def _apply_wildcard_payload(
|
||||
p: dict[str, Any], nmap: dict[str, str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
|
||||
if nmap is None:
|
||||
nmap = _name_map(reg)
|
||||
out = {**p, "sub_categories": [nmap[s] for s in reg]}
|
||||
new_opts: list[Any] = []
|
||||
for o in p.get("options", []):
|
||||
if not isinstance(o, dict):
|
||||
new_opts.append(o)
|
||||
continue
|
||||
o = dict(o)
|
||||
if isinstance(o.get("sub_categories"), list): # already-v2 / idempotent
|
||||
o["sub_categories"] = [nmap.get(t, _slug(t)) for t in o["sub_categories"]]
|
||||
else:
|
||||
sc = o.pop("sub_category", None)
|
||||
o["sub_categories"] = (
|
||||
[nmap.get(sc, _slug(sc))] if isinstance(sc, str) and sc else []
|
||||
)
|
||||
if isinstance(o.get("value"), str):
|
||||
o["value"] = _rewrite_refs(o["value"])
|
||||
new_opts.append(o)
|
||||
out["options"] = new_opts
|
||||
if isinstance(p.get("tag_groups"), dict):
|
||||
out["tag_groups"] = {
|
||||
g: [nmap.get(m, _slug(m)) for m in mem if isinstance(m, str)]
|
||||
for g, mem in p["tag_groups"].items()
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def _migrate_instance(inst: dict[str, Any], nmap: dict[str, str]) -> dict[str, Any]:
|
||||
cf = inst.get("category_filter")
|
||||
if not isinstance(cf, list):
|
||||
return inst
|
||||
out = dict(inst)
|
||||
has_null = "null" in cf
|
||||
tags = [nmap.get(c, _slug(c)) for c in cf if isinstance(c, str) and c and c != "null"]
|
||||
out["category_filter"] = " or ".join(tags)
|
||||
out["exclude_null"] = bool(out.get("exclude_null")) or has_null
|
||||
return out
|
||||
|
||||
|
||||
def _deep_migrate(obj: Any) -> Any:
|
||||
"""Recursive transform shared by the per-row module/bundle migrators:
|
||||
migrate any wildcard-shaped dict in place, rewrite `@{}` refs in every
|
||||
string, recurse into containers. Idempotent."""
|
||||
if isinstance(obj, dict):
|
||||
if _is_wildcard_payload(obj):
|
||||
return _apply_wildcard_payload(obj)
|
||||
return {k: _deep_migrate(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_deep_migrate(x) for x in obj]
|
||||
if isinstance(obj, str):
|
||||
return _rewrite_refs(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def migrate_module_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
|
||||
"""Per-row module-payload migrator for the lazy/bulk DB path."""
|
||||
return _deep_migrate(payload)
|
||||
|
||||
|
||||
def migrate_bundle_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
|
||||
"""Per-row bundle-payload migrator — recurses into frozen child snapshots."""
|
||||
return _deep_migrate(payload)
|
||||
|
||||
|
||||
def _migrate_wildcard_entry(w: dict[str, Any]) -> dict[str, Any]:
|
||||
p = w.get("payload")
|
||||
nmap: dict[str, str] = {}
|
||||
out = dict(w)
|
||||
if isinstance(p, dict):
|
||||
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
|
||||
nmap = _name_map(reg)
|
||||
out["payload"] = _apply_wildcard_payload(p, nmap)
|
||||
inst = w.get("instance")
|
||||
if isinstance(inst, dict):
|
||||
out["instance"] = _migrate_instance(inst, nmap)
|
||||
return out
|
||||
|
||||
|
||||
def migrate_v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Envelope migrator (mirrors the TS chain step). Maps each wildcard
|
||||
entry structurally (+ its instance), deep-migrates the other entity
|
||||
arrays (ref rewrite + any nested wildcard snapshots), bumps to v2."""
|
||||
out = dict(payload)
|
||||
out["wildcards"] = [
|
||||
_migrate_wildcard_entry(w) if isinstance(w, dict) else w
|
||||
for w in payload.get("wildcards", [])
|
||||
]
|
||||
for key in ("bundles", "fixed_values", "combines", "derivations",
|
||||
"constraints", "categories", "templates"):
|
||||
if isinstance(payload.get(key), list):
|
||||
out[key] = [_deep_migrate(e) for e in payload[key]]
|
||||
out["schema_version"] = 2
|
||||
return out
|
||||
+57
-18
@@ -31,15 +31,30 @@ class _RuntimeResolveContext:
|
||||
# bucket is empty/None.
|
||||
_constraints: list[dict[str, Any]] = field(default_factory=list)
|
||||
_picks: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
# First-instance consume bookkeeping (2026-05-24 spec). Threaded
|
||||
# by reference so mutations inside apply_constraints_for_target
|
||||
# propagate back to the pipeline's ctx bucket and to subsequent
|
||||
# target-instance resolves later in the same chain.
|
||||
_consumed: set[str] = field(default_factory=set)
|
||||
# SP3 reach-selector hit counter (per-constraint, keyed by
|
||||
# `__constraint_module_id__`). Threaded by reference so increments
|
||||
# inside apply_constraints_for_target on the nested-ref path share
|
||||
# one counter with the chain-level direct path + the pipeline's ctx
|
||||
# bucket — first/next coverage spans both surfaces.
|
||||
_hits: dict[str, int] = field(default_factory=dict)
|
||||
# SP3 nested-pick carrier identity for the CURRENT resolve frame:
|
||||
# the wildcard whose chosen option value textually contains the
|
||||
# `@{T}` ref being resolved, plus that option's id. MUTABLE +
|
||||
# save/restored by the resolver around each nested-option recursion
|
||||
# (mirroring the rng swap) so `_resolve_ref` can tell
|
||||
# apply_constraints_for_target which (carrier_uid, option_id) is
|
||||
# firing target T — the only thing a `pick` selector's `nested`
|
||||
# occurrence matches on. None outside any carrier option.
|
||||
_carrier_uid: str | None = None
|
||||
_carrier_option_id: str | None = None
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
# SP2a: return the raw stored value (may be a ListVar from a
|
||||
# multi-select wildcard); the resolver formats it. Typed str | None
|
||||
# for the Protocol — a ListVar flows through at runtime via the
|
||||
# Any-valued vars dict, and the resolver narrows on isinstance.
|
||||
if name in self._vars:
|
||||
return str(self._vars[name])
|
||||
return self._vars[name]
|
||||
return None
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
@@ -71,13 +86,37 @@ class _RuntimeResolveContext:
|
||||
source wildcard's pick when reweighting the target."""
|
||||
return self._picks
|
||||
|
||||
def get_consumed_constraints(self) -> set[str]:
|
||||
"""``ctx['__wp_consumed_constraints__']`` — set of constraint
|
||||
module ids that have already fired against their first
|
||||
downstream target instance. Threaded by reference so the
|
||||
nested-ref resolver's mutation is observed by the pipeline + by
|
||||
subsequent target-instance resolves later in the chain."""
|
||||
return self._consumed
|
||||
def get_constraint_hits(self) -> dict[str, int]:
|
||||
"""``ctx['__wp_constraint_hits__']`` — per-constraint firing
|
||||
count keyed by module id. Threaded by reference so the
|
||||
nested-ref resolver's increments are observed by the pipeline +
|
||||
by subsequent target-instance resolves later in the chain
|
||||
(first/next coverage spans direct + nested encounters)."""
|
||||
return self._hits
|
||||
|
||||
def set_carrier(self, carrier_uid: str | None, option_id: str | None) -> None:
|
||||
"""Set the CURRENT carrier identity (the wildcard whose chosen
|
||||
option value contains the `@{T}` ref about to resolve, + that
|
||||
option's id). The resolver save/restores this around each nested
|
||||
option recursion (mirroring the rng swap)."""
|
||||
self._carrier_uid = carrier_uid
|
||||
self._carrier_option_id = option_id
|
||||
|
||||
def get_carrier(self) -> tuple[str | None, str | None]:
|
||||
"""Return the current ``(carrier_uid, option_id)`` so the
|
||||
resolver can save it before a recursion and restore it after."""
|
||||
return self._carrier_uid, self._carrier_option_id
|
||||
|
||||
def get_carrier_ctx(self) -> dict[str, Any] | None:
|
||||
"""The current carrier as the dict shape
|
||||
``apply_constraints_for_target`` /
|
||||
``_constraints._occurrence_matches`` expect, or ``None`` when no
|
||||
carrier is set (outside any carrier option — e.g. a top-level
|
||||
ref). A `pick` selector's `nested` entry matches on these two
|
||||
keys; every other selector mode ignores them."""
|
||||
if self._carrier_uid is None and self._carrier_option_id is None:
|
||||
return None
|
||||
return {"carrier_uid": self._carrier_uid, "option_id": self._carrier_option_id}
|
||||
|
||||
|
||||
def build_resolve_ctx(
|
||||
@@ -93,7 +132,7 @@ def build_resolve_ctx(
|
||||
"""
|
||||
constraints = ctx.get("__wp_constraints__")
|
||||
picks = ctx.get("__wp_picks__")
|
||||
consumed = ctx.setdefault("__wp_consumed_constraints__", set())
|
||||
hits = ctx.setdefault("__wp_constraint_hits__", {})
|
||||
return _RuntimeResolveContext( # type: ignore[return-value]
|
||||
rng=ctx["__wp_rng__"],
|
||||
max_ref_depth=int(ctx.get("__wp_max_ref_depth__", 8)),
|
||||
@@ -110,10 +149,10 @@ def build_resolve_ctx(
|
||||
# support) — defaults handle that path.
|
||||
_constraints=constraints if isinstance(constraints, list) else [],
|
||||
_picks=picks if isinstance(picks, dict) else {},
|
||||
# First-instance consume set — passed by reference so the
|
||||
# nested-ref resolver's mutation sticks in ctx for subsequent
|
||||
# target-instance resolves later in the chain.
|
||||
_consumed=consumed if isinstance(consumed, set) else set(),
|
||||
# SP3 hit counter — passed by reference so the nested-ref
|
||||
# resolver's increments stick in ctx + share one counter with
|
||||
# the direct path for first/next coverage.
|
||||
_hits=hits if isinstance(hits, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Pure constraint re-weight math (SP3). No engine/ctx imports — testable bare.
|
||||
|
||||
One multiply operator at three levels: across an option's matching matrix
|
||||
cells (multi-tag), across source picks (multi-pick), and — by the caller —
|
||||
across constraints. `exclude` (factor 0) is the absorbing element.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
EXCLUDE = object() # sentinel: option drops out of the pool
|
||||
|
||||
def _apply_rule(rule: dict[str, Any]):
|
||||
mode = rule.get("mode")
|
||||
if mode == "exclude":
|
||||
return EXCLUDE
|
||||
if mode in ("boost", "reduce"):
|
||||
try:
|
||||
return max(0.0, float(rule.get("factor", 1.0)))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return 1.0 # allow / unknown -> no weight change
|
||||
|
||||
def combine_constraint_factor(source_picks, option, matrix, exceptions):
|
||||
matrix = matrix or {}
|
||||
exc_by_pair: dict[tuple[str, str], dict] = {}
|
||||
for e in (exceptions or []):
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
s = e.get("source_value")
|
||||
if s is None:
|
||||
s = e.get("source")
|
||||
t = e.get("target_value")
|
||||
if t is None:
|
||||
t = e.get("target")
|
||||
if isinstance(s, str) and isinstance(t, str):
|
||||
exc_by_pair[(s, t)] = e
|
||||
opt_value = str(option.get("value", ""))
|
||||
opt_tags = option.get("tags") or []
|
||||
factor = 1.0
|
||||
for pick in (source_picks or []):
|
||||
p_value = str(pick.get("value", ""))
|
||||
p_tags = pick.get("tags") or []
|
||||
exc = exc_by_pair.get((p_value, opt_value))
|
||||
if exc is not None:
|
||||
r = _apply_rule(exc)
|
||||
if r is EXCLUDE:
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
continue
|
||||
for s in p_tags:
|
||||
row = matrix.get(s)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
for t in opt_tags:
|
||||
rule = row.get(t)
|
||||
if isinstance(rule, dict):
|
||||
r = _apply_rule(rule)
|
||||
if r is EXCLUDE:
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
return factor
|
||||
+131
-116
@@ -17,89 +17,10 @@ handler) or narrow ResolveContext-style accessors (ref resolver).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.modules.wildcard_handler import _apply_constraint_to_options
|
||||
|
||||
# Mirror of `engine.syntax.tokenize._REF_RE` (kept local to avoid
|
||||
# importing a private symbol across the module boundary). Captures the
|
||||
# 8-hex uuid; the optional `#name` / `:subcat` segments are skipped.
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}")
|
||||
|
||||
|
||||
def claim_carrier_constraints(
|
||||
options: list[dict[str, Any]],
|
||||
constraints: list[dict[str, Any]] | None,
|
||||
picks: dict[str, dict[str, Any]] | None,
|
||||
consumed: set[str] | None,
|
||||
) -> None:
|
||||
"""First-instance positional-claim failsafe (2026-05-26).
|
||||
|
||||
After a wildcard rolls + resolves its chosen option, any *unconsumed*
|
||||
constraint whose target this wildcard CARRIES — i.e. has an
|
||||
``@{target}`` ref in ANY of its options, not just the chosen one — is
|
||||
consumed-as-skipped, provided the constraint's source has already
|
||||
been picked.
|
||||
|
||||
Why: the pair badge assigns a constraint to the FIRST downstream
|
||||
wildcard that could host its target (direct instance OR nested-ref
|
||||
carrier). Without this failsafe the runtime only consumed a
|
||||
constraint when the target *actually* resolved, so a carrier whose
|
||||
chosen option happened to omit the ref let the constraint "spill"
|
||||
onto a later target instance — contradicting the badge and
|
||||
surprising the user. Claiming here binds the constraint to the
|
||||
carrier positionally: if the carrier's roll skips the ref, the
|
||||
constraint is spent (no spill) and no never_applied warning fires
|
||||
for it (the carrier did roll — it just didn't use the ref).
|
||||
|
||||
No-op cases:
|
||||
- chosen option DID resolve the ref → the nested-resolve path
|
||||
already consumed the constraint; the ``cid in consumed`` guard
|
||||
skips it here.
|
||||
- source not yet picked → don't claim (matches the direct-target
|
||||
apply path, which also defers + warns rather than consuming an
|
||||
un-appliable constraint). Lets it potentially fire once the
|
||||
source is available later in the chain.
|
||||
"""
|
||||
if not constraints or not isinstance(constraints, list):
|
||||
return
|
||||
if consumed is None:
|
||||
return
|
||||
if not isinstance(picks, dict):
|
||||
picks = {}
|
||||
carried: set[str] = set()
|
||||
for opt in options:
|
||||
val = opt.get("value")
|
||||
if isinstance(val, str):
|
||||
for m in _REF_RE.finditer(val):
|
||||
carried.add(m.group(1))
|
||||
if not carried:
|
||||
return
|
||||
# Claim at most ONE constraint per carried target — the first
|
||||
# unconsumed one in registration order. A carrier hosts a single
|
||||
# nested resolution per target, so it consumes a single constraint
|
||||
# for it (matching `apply_constraints_for_target`, which also fires
|
||||
# one per call). Claiming ALL would let a carrier swallow an entire
|
||||
# family of same-target constraints, starving a later direct target
|
||||
# instance that the pair badge assigns to constraint #2.
|
||||
claimed_targets: set[str] = set()
|
||||
for c in constraints:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
tgt = c.get("target_wildcard_id")
|
||||
if tgt not in carried or tgt in claimed_targets:
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
if cid is None or cid in consumed:
|
||||
continue
|
||||
src_id = c.get("source_wildcard_id")
|
||||
src_pick = picks.get(src_id) if isinstance(src_id, str) else None
|
||||
if not isinstance(src_pick, dict):
|
||||
continue
|
||||
consumed.add(cid)
|
||||
claimed_targets.add(tgt)
|
||||
|
||||
|
||||
def _push_constraint_warning(
|
||||
warnings: list[dict[str, Any]],
|
||||
@@ -126,6 +47,80 @@ def _push_constraint_warning(
|
||||
})
|
||||
|
||||
|
||||
def _occurrence_matches(pick_entries, firing_uid, carrier_ctx) -> bool:
|
||||
"""True when an explicit ``pick`` reach selector covers THIS firing
|
||||
target occurrence.
|
||||
|
||||
Two occurrence kinds:
|
||||
- ``direct`` — a top-level wildcard instance. Matched by its
|
||||
per-instance uid (``firing_uid``). ``carrier_ctx`` is ``None``
|
||||
for these (the wildcard handler fires them directly).
|
||||
- ``nested`` — a ``@{target}`` ref resolved inside a carrier
|
||||
wildcard's chosen option. Matched by (carrier_uid, option_id).
|
||||
``carrier_ctx`` carries those, threaded by the nested-resolve
|
||||
path (``engine.syntax.resolve``). The direct-firing wildcard
|
||||
handler passes ``carrier_ctx=None`` (it fires top-level
|
||||
instances, not nested refs), so the two kinds never cross-match.
|
||||
"""
|
||||
for p in pick_entries:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if carrier_ctx is None:
|
||||
if p.get("kind") == "direct" and p.get("uid") == firing_uid:
|
||||
return True
|
||||
else:
|
||||
if (
|
||||
p.get("kind") == "nested"
|
||||
and p.get("carrier_uid") == carrier_ctx.get("carrier_uid")
|
||||
and p.get("option_id") == carrier_ctx.get("option_id")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _select_source_pick(
|
||||
picks: dict[str, dict[str, Any]],
|
||||
src_id: Any,
|
||||
constraint: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Resolve the source pick a constraint should re-weight against.
|
||||
|
||||
Selection rule (task_5200c1fc — source-instance binding):
|
||||
1. Look up the top-level entry `picks[src_id]` (today's bucket, the
|
||||
last-writer-wins survivor when two instances share a library uuid).
|
||||
2. If the constraint carries a `bundle_origin` AND that entry has a
|
||||
`by_origin[origin]` view, return that view — the source instance in
|
||||
the SAME bundle copy as this constraint.
|
||||
3. Otherwise return the top-level entry as-is = TODAY'S behavior,
|
||||
exactly. Covers: a no-bundle constraint, a constraint inside a
|
||||
bundle whose source is OUTSIDE the bundle, and every legacy
|
||||
workflow. This exact-fallback is what makes the change safe.
|
||||
|
||||
DOCUMENTED LIMITATION (task_5200c1fc): the same library wildcard added
|
||||
MANUALLY twice OUTSIDE any bundle shares no `bundle_origin`, so both
|
||||
manual instances land in the same top-level bucket and the constraint
|
||||
falls back to last-writer-wins. The constraint payload genuinely does
|
||||
not encode WHICH manual instance the user meant, so the design declines
|
||||
to guess rather than guessing wrong. This is a principled residual, not
|
||||
a gap; an explicit stored `source_uid` binding (Option 2 in the design)
|
||||
can be layered on later with precedence explicit > bundle-scope >
|
||||
fallback, foreclosing nothing.
|
||||
"""
|
||||
if not isinstance(src_id, str):
|
||||
return None
|
||||
top = picks.get(src_id)
|
||||
if not isinstance(top, dict):
|
||||
return None
|
||||
origin = constraint.get("__constraint_bundle_origin__")
|
||||
if origin:
|
||||
by_origin = top.get("by_origin")
|
||||
if isinstance(by_origin, dict):
|
||||
scoped = by_origin.get(origin)
|
||||
if isinstance(scoped, dict):
|
||||
return scoped
|
||||
return top
|
||||
|
||||
|
||||
def apply_constraints_for_target(
|
||||
options: list[dict[str, Any]],
|
||||
target_uuid: str,
|
||||
@@ -133,54 +128,60 @@ def apply_constraints_for_target(
|
||||
picks: dict[str, dict[str, Any]] | None,
|
||||
warnings: list[dict[str, Any]],
|
||||
*,
|
||||
consumed: set[str] | None = None,
|
||||
consumed=None,
|
||||
hits=None,
|
||||
firing_uid=None,
|
||||
carrier_ctx=None,
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""Apply the FIRST unconsumed constraint targeting ``target_uuid``.
|
||||
"""Apply EVERY constraint whose reach selector covers this firing
|
||||
target instance, combined sequentially.
|
||||
|
||||
Returns ``(adjusted_options, any_applied)``. ``any_applied`` is True
|
||||
iff a constraint actually fired — callers use it to decide whether
|
||||
to run the excludes-all check.
|
||||
iff at least one constraint actually fired — callers use it to
|
||||
decide whether to run the excludes-all check.
|
||||
|
||||
**One-shot semantic (2026-05-24 first-instance redesign).** Each
|
||||
constraint module is a single ammo round: it fires on the first
|
||||
downstream target wildcard instance, gets marked consumed via its
|
||||
``__constraint_module_id__`` key, and is skipped on subsequent
|
||||
target instances. A second target instance later in the chain
|
||||
claims the NEXT unconsumed constraint, not the same one again.
|
||||
**Reach selector (SP3).** Each constraint carries
|
||||
``target_select = {mode, count?, picks?}`` (default ``{mode: "all"}``).
|
||||
On every firing target instance the per-constraint hit counter
|
||||
``hits[cid]`` increments; the selector then decides coverage:
|
||||
- ``all`` — covers every instance (re-weights every downstream
|
||||
target occurrence).
|
||||
- ``first`` — covers only the 1st (``n == 1``).
|
||||
- ``next`` — covers the first ``count`` (``n <= count``).
|
||||
- ``pick`` — covers the explicitly-listed occurrences
|
||||
(``_occurrence_matches``).
|
||||
Multiple covering constraints stack: their factors multiply in
|
||||
registration order and ``exclude`` is absorbing. This replaces the
|
||||
pre-SP3 one-shot / consumed-set model.
|
||||
|
||||
Constraints are walked in registration (chain) order so the first
|
||||
unconsumed match wins. After applying, the function **breaks** out
|
||||
of the loop — exactly one constraint application per call.
|
||||
Reach is downstream-relative by construction: a constraint only
|
||||
appears in ``ctx["__wp_constraints__"]`` after its own module ran,
|
||||
so target occurrences upstream of it never see it.
|
||||
|
||||
``consumed`` is the ctx-resident ``__wp_consumed_constraints__``
|
||||
set; the caller is expected to thread it from ctx. ``None`` is
|
||||
accepted only by legacy test paths that pre-date the consume model.
|
||||
``hits`` is the ctx-resident ``__wp_constraint_hits__`` dict; the
|
||||
caller threads it from ctx so first/next counters persist across the
|
||||
run (direct + nested encounters share one counter). ``consumed`` is
|
||||
accepted but ignored — kept so legacy callers don't break.
|
||||
|
||||
Each constraint warns + skips when its source wildcard hasn't been
|
||||
picked yet — caller code is responsible for ordering source
|
||||
wildcards before the target's roll. The warning gives the user a
|
||||
signal in WP_Debug instead of a silent no-op.
|
||||
|
||||
See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
|
||||
"""
|
||||
if not constraints or not isinstance(constraints, list):
|
||||
return options, False
|
||||
if not isinstance(picks, dict):
|
||||
picks = {}
|
||||
if consumed is None:
|
||||
consumed = set()
|
||||
if hits is None:
|
||||
hits = {}
|
||||
|
||||
any_applied = False
|
||||
for c in constraints:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
if c.get("target_wildcard_id") != target_uuid:
|
||||
if not isinstance(c, dict) or c.get("target_wildcard_id") != target_uuid:
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
if cid is not None and cid in consumed:
|
||||
continue
|
||||
src_id = c.get("source_wildcard_id")
|
||||
src_pick = picks.get(src_id) if isinstance(src_id, str) else None
|
||||
src_pick = _select_source_pick(picks, src_id, c)
|
||||
if not isinstance(src_pick, dict):
|
||||
_push_constraint_warning(
|
||||
warnings,
|
||||
@@ -195,14 +196,33 @@ def apply_constraints_for_target(
|
||||
},
|
||||
)
|
||||
continue
|
||||
adjustment_warnings: list[dict[str, Any]] = []
|
||||
options = _apply_constraint_to_options(
|
||||
options, c, src_pick, adjustment_warnings,
|
||||
)
|
||||
if cid is not None:
|
||||
consumed.add(cid)
|
||||
hits[cid] = hits.get(cid, 0) + 1
|
||||
n = hits.get(cid, 1)
|
||||
sel = c.get("target_select") or {"mode": "all"}
|
||||
mode = sel.get("mode", "all")
|
||||
if mode == "first":
|
||||
covered = n == 1
|
||||
elif mode == "next":
|
||||
try:
|
||||
cnt = int(sel.get("count", 1))
|
||||
except (TypeError, ValueError):
|
||||
cnt = 1
|
||||
covered = n <= cnt
|
||||
elif mode == "pick":
|
||||
covered = _occurrence_matches(
|
||||
sel.get("picks") or [], firing_uid, carrier_ctx
|
||||
)
|
||||
else:
|
||||
covered = True
|
||||
if not covered:
|
||||
continue
|
||||
adjust_warnings: list[dict[str, Any]] = []
|
||||
options = _apply_constraint_to_options(
|
||||
options, c, src_pick, adjust_warnings,
|
||||
)
|
||||
any_applied = True
|
||||
for w in adjustment_warnings:
|
||||
for w in adjust_warnings:
|
||||
_push_constraint_warning(
|
||||
warnings,
|
||||
type_=w.get("type", "unknown_constraint_mode"),
|
||||
@@ -213,11 +233,6 @@ def apply_constraints_for_target(
|
||||
"target_wildcard_id": target_uuid,
|
||||
},
|
||||
)
|
||||
# Key change for first-instance semantic: one constraint per
|
||||
# target-instance call. Subsequent target instances re-enter
|
||||
# this function and pick up the NEXT unconsumed constraint.
|
||||
break
|
||||
|
||||
return options, any_applied
|
||||
|
||||
|
||||
|
||||
@@ -101,7 +101,16 @@ class CombineHandler(ModuleHandler):
|
||||
# - chain seed otherwise → re-rolls each queue
|
||||
# Both feed derive_module_rng(seed, output_var) so locking with
|
||||
# locked_seed = chain_seed reproduces the unlocked roll exactly.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse the frame-0 resolved value verbatim (from the
|
||||
# pipeline's base pass) so a held combine's alternation stays
|
||||
# frozen across the whole run, nested refs included.
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict) and output_var in _hb:
|
||||
return {output_var: _hb[output_var]}
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
|
||||
@@ -24,6 +24,55 @@ from engine.modules.dispatcher import ModuleHandler
|
||||
|
||||
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
|
||||
|
||||
# SP3 reach selector — which downstream target instance(s) a constraint
|
||||
# reaches. Shape only here; reach *behaviour* is applied by a later task.
|
||||
_REACH_MODES = {"first", "next", "all", "pick"}
|
||||
|
||||
|
||||
def _validate_target_select(ts):
|
||||
"""Validate the optional ``target_select`` reach selector.
|
||||
|
||||
Shape: ``{mode: "first"|"next"|"all"|"pick", count?, picks?}``. ``None``
|
||||
(absent) is allowed — the resolver defaults it to ``{mode: "all"}``.
|
||||
"""
|
||||
if ts is None:
|
||||
return
|
||||
if not isinstance(ts, dict):
|
||||
raise ValueError("constraint payload.target_select must be an object")
|
||||
mode = ts.get("mode", "all")
|
||||
if mode not in _REACH_MODES:
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.mode must be one of {sorted(_REACH_MODES)}"
|
||||
)
|
||||
if mode == "next":
|
||||
count = ts.get("count")
|
||||
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
|
||||
raise ValueError(
|
||||
"constraint payload.target_select.count must be an int >= 1 for mode 'next'"
|
||||
)
|
||||
if mode == "pick":
|
||||
picks = ts.get("picks")
|
||||
if not isinstance(picks, list):
|
||||
raise ValueError(
|
||||
"constraint payload.target_select.picks must be a list for mode 'pick'"
|
||||
)
|
||||
for i, p in enumerate(picks):
|
||||
if not isinstance(p, dict) or p.get("kind") not in ("direct", "nested"):
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.picks[{i}].kind must be 'direct' or 'nested'"
|
||||
)
|
||||
if p["kind"] == "direct" and not isinstance(p.get("uid"), str):
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.picks[{i}].uid must be a string"
|
||||
)
|
||||
if p["kind"] == "nested" and not (
|
||||
isinstance(p.get("carrier_uid"), str) and isinstance(p.get("option_id"), str)
|
||||
):
|
||||
raise ValueError(
|
||||
f"constraint payload.target_select.picks[{i}] nested "
|
||||
"needs carrier_uid + option_id"
|
||||
)
|
||||
|
||||
|
||||
def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
|
||||
"""Append ``meta`` to ``ctx['__wp_constraints__']`` (best-effort).
|
||||
@@ -185,6 +234,7 @@ class ConstraintHandler(ModuleHandler):
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}].factor must not be negative"
|
||||
)
|
||||
_validate_target_select(payload.get("target_select"))
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
@@ -208,6 +258,16 @@ class ConstraintHandler(ModuleHandler):
|
||||
exception_factor_overrides = instance.get("exception_factor_overrides") or {}
|
||||
extra_exceptions = instance.get("extra_exceptions") or []
|
||||
|
||||
# SP3 reach selector — instance override wins over the payload's
|
||||
# value, falling back to the {mode:"all"} default. Shape was
|
||||
# already validated in validate_payload (payload side); the
|
||||
# instance side is validated when the instance is loaded.
|
||||
ts = (
|
||||
instance.get("target_select")
|
||||
or payload.get("target_select")
|
||||
or {"mode": "all"}
|
||||
)
|
||||
|
||||
# ── Matrix: filter then override ─────────────────────────────
|
||||
raw_matrix = payload.get("matrix", {})
|
||||
matrix: dict[str, Any] = {}
|
||||
@@ -353,20 +413,22 @@ class ConstraintHandler(ModuleHandler):
|
||||
exceptions.append(dict(exc))
|
||||
|
||||
# Carry the owning constraint module's id into the registered
|
||||
# bucket entry so `apply_constraints_for_target` can mark it
|
||||
# consumed once its first downstream target instance fires
|
||||
# (one-shot semantic per 2026-05-24 first-instance spec).
|
||||
# bucket entry as `__constraint_module_id__` (the `cid`). Under the
|
||||
# SP3 reach model it identifies the constraint for two things:
|
||||
# (1) the per-constraint hit counter `__wp_constraint_hits__[cid]`
|
||||
# that drives first / next / pick coverage in
|
||||
# `apply_constraints_for_target`, and (2) the dedup key + ref of
|
||||
# the never-applied / partial-reach finalisation warnings.
|
||||
#
|
||||
# Key on the PER-INSTANCE uid (`__wp_current_module_uid__`), not
|
||||
# the library uuid (`__wp_current_module_id__`): two instances of
|
||||
# the same library constraint entry must be INDEPENDENT one-shots
|
||||
# the same library constraint entry must reach INDEPENDENTLY
|
||||
# (CLAUDE.md — "author multiple constraint modules" to affect
|
||||
# multiple target instances). Pre-2026-05-26 both keyed on the
|
||||
# shared library uuid, so claiming/consuming one silently spent
|
||||
# the other — a carrier could swallow the whole family and a
|
||||
# downstream direct target then rolled unconstrained. The pipeline
|
||||
# falls back to the library id when `_uid` is absent (legacy /
|
||||
# hand-built test modules), preserving the old behaviour there.
|
||||
# shared library uuid, so the two instances' counters collided —
|
||||
# one instance's encounters advanced the other's first/next
|
||||
# selector. The pipeline falls back to the library id when `_uid`
|
||||
# is absent (legacy / hand-built test modules).
|
||||
module_id = None
|
||||
try:
|
||||
if hasattr(ctx, "get"):
|
||||
@@ -376,27 +438,33 @@ class ConstraintHandler(ModuleHandler):
|
||||
)
|
||||
except Exception:
|
||||
module_id = None
|
||||
# The per-instance `_uid` (12+ chars) keys the consumed-set so two
|
||||
# canvas instances of the same library constraint stay independent
|
||||
# one-shots. The LIBRARY id (8-hex) is also stashed because the
|
||||
# never-applied warning text needs it to render the chip — the SPA
|
||||
# / Debug viewer's chip resolver looks up library uuids in the
|
||||
# module catalog, and a per-instance `_uid` would never resolve
|
||||
# (silently rendering as raw `@{…}` text in the warning).
|
||||
# The per-instance `_uid` (12+ chars) keys the per-constraint hit
|
||||
# counter so two canvas instances of the same library constraint
|
||||
# reach independently. The LIBRARY id (8-hex) is also stashed
|
||||
# because the never-applied / partial-reach warning text needs it
|
||||
# to render the chip — the SPA / Debug viewer's chip resolver looks
|
||||
# up library uuids in the module catalog, and a per-instance `_uid`
|
||||
# would never resolve (silently rendering as raw `@{…}` text in the
|
||||
# warning).
|
||||
library_id = None
|
||||
library_name = None
|
||||
bundle_origin = None
|
||||
try:
|
||||
if hasattr(ctx, "get"):
|
||||
library_id = ctx.get("__wp_current_module_id__")
|
||||
library_name = ctx.get("__wp_current_module_name__")
|
||||
bundle_origin = ctx.get("__wp_current_module_bundle_origin__")
|
||||
except Exception:
|
||||
library_id = None
|
||||
library_name = None
|
||||
bundle_origin = None
|
||||
meta = {
|
||||
"source_wildcard_id": payload["source_wildcard_id"],
|
||||
"target_wildcard_id": payload["target_wildcard_id"],
|
||||
"matrix": matrix,
|
||||
"exceptions": exceptions,
|
||||
# SP3 reach selector — recorded for the (later) apply pass.
|
||||
"target_select": ts,
|
||||
"__constraint_module_id__": module_id,
|
||||
"__constraint_library_id__": library_id,
|
||||
# Cached display name (e.g. "Starter pairing"). The never-applied
|
||||
@@ -407,6 +475,15 @@ class ConstraintHandler(ModuleHandler):
|
||||
# endpoint would 404 there, leaving the chip stuck on the raw
|
||||
# uuid otherwise.
|
||||
"__constraint_library_name__": library_name,
|
||||
# Per-insertion bundle discriminator (task_5200c1fc). When this
|
||||
# constraint instance lives inside a bundle copy, it shares one
|
||||
# `bundle_origin` with the source wildcard instance in the SAME
|
||||
# copy. `apply_constraints_for_target` uses it to read that
|
||||
# copy's source pick (`by_origin[origin]`) rather than the
|
||||
# last-writer survivor. Falsy (None) for a top-level / manual /
|
||||
# legacy constraint — then apply falls back to today's
|
||||
# last-writer-wins lookup, unchanged.
|
||||
"__constraint_bundle_origin__": bundle_origin,
|
||||
}
|
||||
_ctx_set_constraint(ctx, meta)
|
||||
return {}
|
||||
|
||||
@@ -19,6 +19,7 @@ from engine.modules import build_resolve_ctx
|
||||
from engine.modules._seed import derive_module_rng
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
from engine.syntax.types import deref_var_value, split_var_accessor
|
||||
|
||||
_VALID_OPS = {
|
||||
"equals", "not_equals", "contains", "matches",
|
||||
@@ -33,25 +34,34 @@ _VALID_OPS = {
|
||||
_VALID_MODES = {"replace", "append", "prepend"}
|
||||
|
||||
|
||||
def _ctx_get(ctx: Any, name: str) -> str:
|
||||
def _ctx_get_raw(ctx: Any, name: str) -> Any:
|
||||
"""Read the RAW stored value for `name` (may be a ListVar) through the
|
||||
callable-getter -> dict-lookup indirection. Missing / error -> None."""
|
||||
if ctx is None:
|
||||
return ""
|
||||
return None
|
||||
getter = getattr(ctx, "get", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
value = getter(name, "")
|
||||
return getter(name, None)
|
||||
except TypeError:
|
||||
try:
|
||||
value = getter(name)
|
||||
return getter(name)
|
||||
except Exception:
|
||||
return ""
|
||||
return "" if value is None else str(value)
|
||||
return None
|
||||
try:
|
||||
if name in ctx: # type: ignore[operator]
|
||||
return str(ctx[name]) # type: ignore[index]
|
||||
return ctx[name] # type: ignore[index]
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _ctx_get(ctx: Any, name: str) -> str:
|
||||
"""Read `name` as a string, honoring a `.K` list accessor and folding a
|
||||
ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` +
|
||||
index 0. The fold/accessor contract lives in deref_var_value."""
|
||||
base, index = split_var_accessor(name)
|
||||
return deref_var_value(_ctx_get_raw(ctx, base), index)
|
||||
|
||||
|
||||
def _ctx_has(ctx: Any, name: str) -> bool:
|
||||
@@ -66,6 +76,7 @@ def _ctx_has(ctx: Any, name: str) -> bool:
|
||||
"""
|
||||
if ctx is None:
|
||||
return False
|
||||
name = split_var_accessor(name)[0] # SP2a: presence checks the base var
|
||||
# Engine ctx is a dict in tests + at runtime; check `__contains__`
|
||||
# before falling back to a getter probe so we get the cheap path.
|
||||
try:
|
||||
@@ -91,6 +102,7 @@ def _ctx_has(ctx: Any, name: str) -> bool:
|
||||
def _ctx_set(ctx: Any, name: str, value: str) -> None:
|
||||
if ctx is None:
|
||||
return
|
||||
name = split_var_accessor(name)[0] # SP2a: writing $x.K targets the base var
|
||||
setter = getattr(ctx, "set", None)
|
||||
if callable(setter):
|
||||
setter(name, value)
|
||||
@@ -132,17 +144,48 @@ def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def branch_carrier_key(rule_id: str, branch: int | str) -> str:
|
||||
"""Carrier key for a derivation branch occurrence -- the `option_id` the
|
||||
SP3 nested-occurrence model matches a constraint `pick` against. `branch`
|
||||
is the 0-based branch index (0 = IF) or the literal ``"else"``.
|
||||
|
||||
MUST stay byte-identical to the TS twin ``branchKey`` in
|
||||
``src/extension/constraint-pairs.ts``; both are corpus-locked against
|
||||
``tests/fixtures/constraint-corpus.json`` ``branch_key_cases``.
|
||||
"""
|
||||
return f"{rule_id}:{branch}"
|
||||
|
||||
|
||||
def _apply_action(
|
||||
action: dict[str, Any],
|
||||
ctx: Any,
|
||||
resolve_ctx: Any,
|
||||
carrier_key: str | None = None,
|
||||
) -> tuple[str, str] | None:
|
||||
target = action.get("target_var", "")
|
||||
if not target:
|
||||
return None
|
||||
mode = action.get("mode", "replace")
|
||||
raw_value = str(action.get("value", ""))
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
# Stamp this derivation instance as the CARRIER of any `@{}` in the action
|
||||
# value, keyed by the branch key (`${rule_id}:${bi}` / `${rule_id}:else`) —
|
||||
# the `option_id` the SP3 nested-occurrence model matches a `pick` against.
|
||||
# Save/restore around the resolve, mirroring the resolver's own carrier
|
||||
# save/restore for nested recursion.
|
||||
set_carrier = getattr(resolve_ctx, "set_carrier", None)
|
||||
get_carrier = getattr(resolve_ctx, "get_carrier", None)
|
||||
if carrier_key is not None and callable(set_carrier) and callable(get_carrier):
|
||||
module_uid = None
|
||||
if isinstance(ctx, dict):
|
||||
module_uid = ctx.get("__wp_current_module_uid__") or ctx.get("__wp_current_module_id__")
|
||||
prev = get_carrier()
|
||||
set_carrier(module_uid, carrier_key)
|
||||
try:
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
finally:
|
||||
set_carrier(*prev)
|
||||
else:
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
if mode == "replace":
|
||||
result = new_value
|
||||
elif mode == "append":
|
||||
@@ -252,8 +295,30 @@ class DerivationHandler(ModuleHandler):
|
||||
# action.value across runs; without lock the chain seed
|
||||
# propagates per-queue. Falls back to the host ctx's RNG when
|
||||
# the ctx wasn't built with `__wp_rng__` (legacy callers).
|
||||
# seed_scope == "hold" freezes the DICE per branch, NOT which branch
|
||||
# fires. The conditions still pick a branch live each iteration, so a
|
||||
# varying input can switch IF→ELIF and we use THAT branch — but every
|
||||
# branch's inline {a|b} / @{} roll is frozen to its frame-0 value via
|
||||
# the constant hold seed, keyed PER BRANCH (see _branch_resolve_ctx).
|
||||
# So each branch's value is identical every iteration it fires and
|
||||
# equals the value it WOULD have had at frame 0, independent of which
|
||||
# other branches fired. (Contrast wildcard/combine/fixed_values hold,
|
||||
# which freeze the whole resolved VALUE because a constraint can
|
||||
# reshape their pool; a derivation branch's pool is fixed, so a frozen
|
||||
# seed alone is enough — and freezing the branch SELECTION would be
|
||||
# wrong: the user wants the chosen branch to follow the inputs.)
|
||||
_hold = False
|
||||
effective_seed = 0
|
||||
module_id = "derivation"
|
||||
ctx_local: dict[str, Any] = {}
|
||||
if isinstance(ctx, dict) and "__wp_rng__" in ctx:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
_hold = instance.get("seed_scope") == "hold"
|
||||
if _hold:
|
||||
chain_seed = int(
|
||||
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
|
||||
)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
effective_seed = (
|
||||
int(locked_seed) if isinstance(locked_seed, (int, float))
|
||||
@@ -266,6 +331,19 @@ class DerivationHandler(ModuleHandler):
|
||||
else:
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
|
||||
|
||||
def _branch_resolve_ctx(branch_key: str) -> Any:
|
||||
# Non-hold (or no rng): the shared rng stream — existing
|
||||
# behaviour, branch values draw in fire order. Hold: an
|
||||
# INDEPENDENT rng per branch (keyed by rule:branch) so its value
|
||||
# is frozen to its frame-0 roll regardless of which other
|
||||
# branches drew before it in the shared stream.
|
||||
if not _hold:
|
||||
return resolve_ctx
|
||||
rng_b = derive_module_rng(effective_seed, f"{module_id}::{branch_key}")
|
||||
return build_resolve_ctx(
|
||||
{**ctx_local, "__wp_rng__": rng_b}, surface="derivation"
|
||||
)
|
||||
|
||||
# Tier-D instance overrides (2026-05-10 cycle).
|
||||
disabled_rule_ids = set(instance.get("disabled_rule_ids") or [])
|
||||
disabled_branch_keys = set(instance.get("disabled_branch_keys") or [])
|
||||
@@ -296,7 +374,7 @@ class DerivationHandler(ModuleHandler):
|
||||
# Branch-level disable — `r1:1` etc. IF (bi=0) ignored
|
||||
# even when listed because disabling IF == disabling rule
|
||||
# (the per-rule toggle handles that case cleanly).
|
||||
if bi != 0 and f"{rule_id}:{bi}" in disabled_branch_keys:
|
||||
if bi != 0 and branch_carrier_key(rule_id, bi) in disabled_branch_keys:
|
||||
continue
|
||||
|
||||
# Condition-value override per branch index.
|
||||
@@ -320,7 +398,11 @@ class DerivationHandler(ModuleHandler):
|
||||
if isinstance(action_override, str):
|
||||
action = {**action, "value": action_override}
|
||||
|
||||
pair = _apply_action(action, ctx, resolve_ctx)
|
||||
pair = _apply_action(
|
||||
action, ctx,
|
||||
_branch_resolve_ctx(branch_carrier_key(rule_id, bi)),
|
||||
carrier_key=branch_carrier_key(rule_id, bi),
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
applied = True
|
||||
@@ -328,7 +410,7 @@ class DerivationHandler(ModuleHandler):
|
||||
|
||||
if not applied:
|
||||
# ELSE skip when listed in disabled_branch_keys.
|
||||
if f"{rule_id}:else" in disabled_branch_keys:
|
||||
if branch_carrier_key(rule_id, "else") in disabled_branch_keys:
|
||||
continue
|
||||
else_clause = rule.get("else")
|
||||
if isinstance(else_clause, dict):
|
||||
@@ -341,7 +423,11 @@ class DerivationHandler(ModuleHandler):
|
||||
)
|
||||
if isinstance(action_override, str):
|
||||
action = {**action, "value": action_override}
|
||||
pair = _apply_action(action, ctx, resolve_ctx)
|
||||
pair = _apply_action(
|
||||
action, ctx,
|
||||
_branch_resolve_ctx(branch_carrier_key(rule_id, "else")),
|
||||
carrier_key=branch_carrier_key(rule_id, "else"),
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
return out
|
||||
|
||||
@@ -150,7 +150,20 @@ class FixedValuesHandler(ModuleHandler):
|
||||
return out
|
||||
|
||||
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse each name's frame-0 resolved value verbatim (from the
|
||||
# pipeline's base pass) so a held fixed-values alternation stays
|
||||
# frozen across the whole run.
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict):
|
||||
_names = [n for n in ((v.get("name") or "").strip() for v in values) if n]
|
||||
if _names and all(n in _hb for n in _names):
|
||||
return {n: _hb[n] for n in _names}
|
||||
chain_seed = int(
|
||||
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
|
||||
)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
|
||||
@@ -70,6 +70,15 @@ def _fresh_instance() -> dict[str, Any]:
|
||||
"enabled_options": None,
|
||||
"category_filter": None,
|
||||
"option_weights": None,
|
||||
# SP2a multi-select + the null toggle — part of the pool-shaping
|
||||
# override set, so a frozen snapshot carries them too (all None =
|
||||
# "no override"). Keeps this dict honest with the "every field" claim
|
||||
# above + the instance schema (engine/schemas/instance_schemas.py).
|
||||
"exclude_null": None,
|
||||
"pick_min": None,
|
||||
"pick_max": None,
|
||||
"pick_separator": None,
|
||||
"pick_independent": None, # SP2c: multi-pick with replacement when true
|
||||
# Pick mode — `None`/missing → `random` (legacy behavior:
|
||||
# weighted RNG over enabled options). `pinned` short-circuits
|
||||
# the RNG and always picks `pinned_option_id`. `subcategory`
|
||||
@@ -202,7 +211,7 @@ class WalkResult:
|
||||
walk_overflow: list[WalkOverflow] = field(default_factory=list)
|
||||
|
||||
|
||||
def _extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
|
||||
def extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
|
||||
"""Collect all ``@{8hex}`` refs referenced inside a payload's
|
||||
string-typed leaves. Uses ``tokenize_text`` exclusively — only
|
||||
canonical 8-hex-char refs (spec §2.4) are followed.
|
||||
@@ -251,6 +260,41 @@ def _row_to_snapshot_entry(
|
||||
}
|
||||
|
||||
|
||||
def ref_seed_uuids(modules: Iterable[Any] | None) -> set[str]:
|
||||
"""Every ``@{uuid}`` target referenced by ANY module in a node's list.
|
||||
|
||||
A catalog is synthesised from wildcard modules only, and
|
||||
``walk_transitive_refs`` follows refs found in wildcard payloads only. That
|
||||
covers a ref authored inside a wildcard option, but ``@{uuid}`` is legal on
|
||||
other surfaces too — most notably a derivation rule's action value, and the
|
||||
per-instance ``action_value_overrides`` / ``condition_value_overrides`` the
|
||||
edit modal writes when a user retargets a branch on the canvas.
|
||||
|
||||
Refs on those surfaces were never discovered, so their target reached the
|
||||
catalog only by coincidence — when the user happened to ALSO have that
|
||||
wildcard picked in the same node. Removing the wildcard from the node (a
|
||||
reasonable thing to do once a derivation references it by uuid) dropped it
|
||||
with nothing to put it back, and every such ref resolved to ``unknown_ref``
|
||||
→ empty string no matter what the live library held.
|
||||
|
||||
Seeds therefore come from each module's ``payload`` AND its ``instance``:
|
||||
the instance is where a canvas-side retarget lives, and it is not part of
|
||||
the library payload the walker would otherwise see.
|
||||
|
||||
Tolerant of malformed rows — a corrupt widget value must never break a
|
||||
graph run.
|
||||
"""
|
||||
seeds: set[str] = set()
|
||||
for module in modules or []:
|
||||
if not isinstance(module, dict):
|
||||
continue
|
||||
for key in ("payload", "instance"):
|
||||
value = module.get(key)
|
||||
if isinstance(value, dict):
|
||||
seeds |= extract_at_uuid_refs(value)
|
||||
return seeds
|
||||
|
||||
|
||||
def walk_transitive_refs(
|
||||
root_uuids: Iterable[str],
|
||||
*,
|
||||
@@ -338,7 +382,7 @@ def walk_transitive_refs(
|
||||
continue
|
||||
|
||||
new_ancestors = ancestors | {uuid}
|
||||
for child_uuid in _extract_at_uuid_refs(row["payload"]):
|
||||
for child_uuid in extract_at_uuid_refs(row["payload"]):
|
||||
queue.append((child_uuid, depth + 1, uuid, new_ancestors))
|
||||
|
||||
# After walk: re-stamp every dep's parent list (BFS may have accumulated
|
||||
|
||||
+438
-183
@@ -24,25 +24,40 @@ from engine.modules import build_resolve_ctx
|
||||
from engine.modules._seed import derive_module_rng as _derive_module_rng
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
from engine.syntax.resolve import pick_k_unique
|
||||
from engine.syntax.subcat_filter import (
|
||||
matches as _subcat_matches,
|
||||
)
|
||||
from engine.syntax.subcat_filter import (
|
||||
parse as _parse_subcat,
|
||||
)
|
||||
from engine.syntax.subcat_filter import (
|
||||
validate_subcat_name,
|
||||
)
|
||||
from engine.syntax.types import ListVar
|
||||
|
||||
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
_MAX_IDENT_LEN = 64
|
||||
|
||||
# Characters reserved by the nested-ref grammar `@{uuid[#name][:subcat,subcat...]}`.
|
||||
# Forbidden in:
|
||||
# - wildcard `meta.name` / library `name` (the `#name` segment)
|
||||
# - wildcard `payload.sub_categories[*]` (the `:subcat` segment)
|
||||
# Comma is in here because subcat lists are comma-separated; the other
|
||||
# chars are structural delimiters of the ref grammar. Mirror in
|
||||
# `src/manager/utils/validateName.ts` so the editor + API agree.
|
||||
_REF_FORBIDDEN_CHARS = frozenset("{}:#@,")
|
||||
|
||||
# `_derive_module_rng` lifted to engine/modules/_seed.py so combine +
|
||||
# fixed_values handlers share the same helper (Phase: combine v2 +
|
||||
# syntax parity cycle). The private alias preserves the existing call
|
||||
# site at line ~376 unchanged.
|
||||
|
||||
|
||||
def _coerce_pick_int(value: Any, default: int) -> int:
|
||||
"""SP2a: coerce a pick-count field (`pick_min`/`pick_max`) to int, falling
|
||||
back to `default` on junk. The engine is reachable by hand-built and legacy
|
||||
instances, so a non-numeric value must degrade to single-pick rather than
|
||||
raise out of resolve() — mirrors the locked_seed coercion guard."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
|
||||
"""Pick one option weighted by `weight`. Uses rng (no module-global random)."""
|
||||
if not options:
|
||||
@@ -66,121 +81,289 @@ def _apply_constraint_to_options(
|
||||
adjustment_warnings: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Adjust option weights according to a single constraint, given the
|
||||
already-picked source option.
|
||||
already-picked source option(s).
|
||||
|
||||
Two layers, in order:
|
||||
1. Matrix lookup keyed by `(source.sub_category, target.sub_category)`.
|
||||
Bulk rule expressing "long hair allows positive moods" etc.
|
||||
2. Exception lookup keyed by the literal `(source.value, target.value)`
|
||||
pair. Overrides the matrix cell when present — narrower rule wins.
|
||||
SP3: weight adjustment is delegated to
|
||||
:func:`engine.modules._constraint_math.combine_constraint_factor`, which
|
||||
multiplies one factor per matching matrix cell (an option on N source-tag
|
||||
rows multiplies N times — true multi-tag) across every source pick (true
|
||||
multi-pick). The exception layer (literal `(source_value, target_value)`
|
||||
pair) still overrides the matrix for that pick, and `exclude` is absorbing
|
||||
(factor 0 → weight 0, option drops out).
|
||||
|
||||
Modes:
|
||||
- ``allow`` → no change to weight (factor honoured but typically 1).
|
||||
- ``exclude`` → weight set to 0 (option drops out of the pool).
|
||||
- ``boost`` → multiply weight by factor (expect factor > 1).
|
||||
- ``reduce`` → multiply weight by factor (expect factor < 1).
|
||||
`source_pick["picks"]` is the per-pick list (`[{value, tags}]`) recorded by
|
||||
`_record_pick` / `_record_pick_multi`. Legacy callers that hand-build a
|
||||
source dict without it fall back to a single synthetic pick from the
|
||||
top-level `value` + `sub_categories`.
|
||||
|
||||
Options not covered by either matrix or exception keep their
|
||||
original weight. Returns shallow-copied list — the input options
|
||||
array is never mutated, so the underlying snapshot stays clean
|
||||
across runs.
|
||||
Returns a shallow-copied list — the input options array is never mutated,
|
||||
so the underlying snapshot stays clean across runs.
|
||||
|
||||
`adjustment_warnings` (optional) collects editor-footgun warnings the
|
||||
caller forwards to the chain warning list:
|
||||
- ``constraint_factor_ignored_on_allow`` — a `mode: allow` cell carries a
|
||||
non-1 factor (user expects weight×factor, engine ignores it).
|
||||
- ``unknown_constraint_mode`` — a typo'd mode (`exlcude`/`alllow`) the
|
||||
combine fn treats as a no-op.
|
||||
These are diagnostic only; they never change the computed weight.
|
||||
"""
|
||||
from engine.modules._constraint_math import EXCLUDE, combine_constraint_factor
|
||||
|
||||
matrix = constraint.get("matrix") or {}
|
||||
exceptions = constraint.get("exceptions") or []
|
||||
src_value = str(source_pick.get("value", ""))
|
||||
src_sub = source_pick.get("sub_category")
|
||||
|
||||
# Index exceptions by (source_value, target_value) for O(1) lookup.
|
||||
# Empty string is a valid key — it's the null-option marker (a
|
||||
# wildcard option with `is_null: True` has `value: ""`). So
|
||||
# `{source: "rain", target: ""}` excludes the null option when the
|
||||
# source rolls "rain". The validator allows empty strings on these
|
||||
# fields for the same reason; only fully-missing keys (None on both
|
||||
# legacy + tier-2 names) are rejected upstream.
|
||||
exc_by_pair: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for exc in exceptions:
|
||||
if not isinstance(exc, dict):
|
||||
continue
|
||||
s = exc.get("source")
|
||||
t = exc.get("target")
|
||||
if isinstance(s, str) and isinstance(t, str):
|
||||
exc_by_pair[(s, t)] = exc
|
||||
# Per-pick source list (SP3). Fall back to a single synthetic pick for
|
||||
# legacy callers that pass only the top-level value + sub_categories.
|
||||
picks = source_pick.get("picks")
|
||||
if not isinstance(picks, list):
|
||||
picks = [{
|
||||
"value": source_pick.get("value", ""),
|
||||
"tags": list(source_pick.get("sub_categories") or []),
|
||||
}]
|
||||
|
||||
matrix_row = matrix.get(src_sub) if src_sub else None
|
||||
# Pre-scan for diagnostic warnings only. The combine fn intentionally
|
||||
# treats `allow` and unknown modes as factor 1.0 (no weight change), so it
|
||||
# can't surface them — mirror its (pick × option) rule selection here,
|
||||
# exception-pair winning over matrix cell, and warn where the selected rule
|
||||
# is an ignored-factor `allow` or an unrecognised mode.
|
||||
if adjustment_warnings is not None:
|
||||
_scan_constraint_warnings(
|
||||
picks, options, matrix, exceptions, adjustment_warnings,
|
||||
)
|
||||
|
||||
adjusted: list[dict[str, Any]] = []
|
||||
for opt in options:
|
||||
opt_value = str(opt.get("value", ""))
|
||||
opt_sub = opt.get("sub_category")
|
||||
option = {"value": opt.get("value", ""), "tags": opt.get("sub_categories") or []}
|
||||
f = combine_constraint_factor(picks, option, matrix, exceptions)
|
||||
weight = float(opt.get("weight", 1))
|
||||
|
||||
# Exception layer (specific value pair) wins over matrix layer.
|
||||
rule = exc_by_pair.get((src_value, opt_value))
|
||||
if rule is None and isinstance(matrix_row, dict) and opt_sub is not None:
|
||||
rule = matrix_row.get(opt_sub)
|
||||
|
||||
if isinstance(rule, dict):
|
||||
mode = rule.get("mode")
|
||||
try:
|
||||
factor = float(rule.get("factor", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
factor = 1.0
|
||||
if mode == "exclude":
|
||||
weight = 0.0
|
||||
elif mode == "boost" or mode == "reduce":
|
||||
weight = max(0.0, weight * factor)
|
||||
elif mode == "allow":
|
||||
# `allow` is a no-op weight-wise; surface a warning when
|
||||
# the SPA stored a non-1 factor on it because that's
|
||||
# almost certainly a user expecting weight*factor and
|
||||
# silently getting full weight.
|
||||
if factor != 1.0:
|
||||
out_warns = adjustment_warnings if adjustment_warnings is not None else None
|
||||
if out_warns is not None:
|
||||
out_warns.append({
|
||||
"type": "constraint_factor_ignored_on_allow",
|
||||
"factor": factor,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
else:
|
||||
# Unknown mode — typo like "exlcude" / "alllow". Silent
|
||||
# no-op was the worst-of-both: weight unchanged + zero
|
||||
# signal that the rule was malformed. Bubble it up so
|
||||
# the caller can emit a `unknown_constraint_mode` warning.
|
||||
out_warns = adjustment_warnings if adjustment_warnings is not None else None
|
||||
if out_warns is not None:
|
||||
out_warns.append({
|
||||
"type": "unknown_constraint_mode",
|
||||
"mode": mode,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
|
||||
weight = 0.0 if f is EXCLUDE else max(0.0, weight * float(f))
|
||||
adjusted.append({**opt, "weight": weight})
|
||||
return adjusted
|
||||
|
||||
|
||||
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce"})
|
||||
|
||||
|
||||
def _exc_pair_index(exceptions: list[Any]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
"""Index exceptions by (source_value, target_value), mirroring the
|
||||
combine fn's key fallback (tier-2 `source_value`/`target_value` names,
|
||||
then legacy `source`/`target`). Empty string is a valid key — it's the
|
||||
null-option marker (`is_null` options carry `value: ""`)."""
|
||||
out: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for exc in exceptions or []:
|
||||
if not isinstance(exc, dict):
|
||||
continue
|
||||
s = exc.get("source_value")
|
||||
if s is None:
|
||||
s = exc.get("source")
|
||||
t = exc.get("target_value")
|
||||
if t is None:
|
||||
t = exc.get("target")
|
||||
if isinstance(s, str) and isinstance(t, str):
|
||||
out[(s, t)] = exc
|
||||
return out
|
||||
|
||||
|
||||
def _scan_constraint_warnings(
|
||||
picks: list[dict[str, Any]],
|
||||
options: list[dict[str, Any]],
|
||||
matrix: dict[str, Any],
|
||||
exceptions: list[Any],
|
||||
adjustment_warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Append `constraint_factor_ignored_on_allow` / `unknown_constraint_mode`
|
||||
diagnostics for the rules that fire (per pick × option). Selection order
|
||||
matches `combine_constraint_factor`: an exception on the literal value pair
|
||||
wins over a matrix cell, and only the matched rule is inspected."""
|
||||
exc_by_pair = _exc_pair_index(exceptions)
|
||||
for pick in picks:
|
||||
p_value = str(pick.get("value", ""))
|
||||
p_tags = pick.get("tags") or []
|
||||
for opt in options:
|
||||
opt_value = str(opt.get("value", ""))
|
||||
opt_tags = opt.get("sub_categories") or []
|
||||
|
||||
exc = exc_by_pair.get((p_value, opt_value))
|
||||
if exc is not None:
|
||||
_warn_for_rule(exc, p_value, opt_value, adjustment_warnings)
|
||||
continue
|
||||
|
||||
for s in p_tags:
|
||||
row = matrix.get(s)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
for t in opt_tags:
|
||||
rule = row.get(t)
|
||||
if isinstance(rule, dict):
|
||||
_warn_for_rule(rule, p_value, opt_value, adjustment_warnings)
|
||||
|
||||
|
||||
def _warn_for_rule(
|
||||
rule: dict[str, Any],
|
||||
src_value: str,
|
||||
opt_value: str,
|
||||
adjustment_warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
mode = rule.get("mode")
|
||||
if mode == "allow":
|
||||
# `allow` is a weight no-op; a non-1 factor is almost certainly a
|
||||
# user expecting weight×factor and silently getting full weight.
|
||||
try:
|
||||
factor = float(rule.get("factor", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
factor = 1.0
|
||||
if factor != 1.0:
|
||||
adjustment_warnings.append({
|
||||
"type": "constraint_factor_ignored_on_allow",
|
||||
"factor": factor,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
elif mode not in _KNOWN_CONSTRAINT_MODES:
|
||||
# Typo like "exlcude" / "alllow". The combine fn no-ops it; bubble up
|
||||
# so the caller can surface `unknown_constraint_mode`.
|
||||
adjustment_warnings.append({
|
||||
"type": "unknown_constraint_mode",
|
||||
"mode": mode,
|
||||
"src_value": src_value,
|
||||
"opt_value": opt_value,
|
||||
})
|
||||
|
||||
|
||||
def _file_pick_by_origin(
|
||||
ctx: Any,
|
||||
entry: dict[str, Any],
|
||||
bucket: dict[str, Any],
|
||||
module_id: Any,
|
||||
) -> None:
|
||||
"""Attach `entry` under `bucket[module_id]["by_origin"][origin]` when the
|
||||
active module carries a `bundle_origin` (stamped by pipeline.py). This is
|
||||
the per-instance source-pick view a constraint in the same bundle copy
|
||||
reads (task_5200c1fc); the top-level `bucket[module_id]` stays the
|
||||
last-writer-wins fallback. No-op when no origin is stamped, so a
|
||||
top-level / manual / legacy module's pick entry is byte-for-byte today's
|
||||
shape. The `by_origin` map is carried FORWARD across re-records of the
|
||||
same module_id so two origins writing the same library uuid each keep
|
||||
their own view (the 2nd record must not drop the 1st origin's bucket).
|
||||
"""
|
||||
origin = ctx.get("__wp_current_module_bundle_origin__") if ctx is not None else None
|
||||
if not origin:
|
||||
return
|
||||
prev = bucket.get(module_id)
|
||||
by_origin = {}
|
||||
if isinstance(prev, dict) and isinstance(prev.get("by_origin"), dict):
|
||||
by_origin = dict(prev["by_origin"])
|
||||
by_origin[origin] = {k: v for k, v in entry.items() if k != "by_origin"}
|
||||
entry["by_origin"] = by_origin
|
||||
|
||||
|
||||
def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
|
||||
"""Stash the picked option dict in `ctx["__wp_picks__"][module_id]` so a
|
||||
downstream constraint-aware wildcard can look up its source's value +
|
||||
sub_category. Keyed by the active module id (set by pipeline.py).
|
||||
sub_categories. Keyed by the active module id (set by pipeline.py).
|
||||
"""
|
||||
# KNOWN COLLISION (task_5200c1fc): module_id is the LIBRARY uuid
|
||||
# (pipeline.py stamps __wp_current_module_id__ = library id). Two
|
||||
# instances of one library wildcard (bundle inserted twice, or the same
|
||||
# wildcard added twice) share this bucket key — the 2nd pick clobbers the
|
||||
# 1st, and every constraint sourcing that uuid reads the survivor. Fix =
|
||||
# key on the per-instance _uid + give constraints a source-INSTANCE bind.
|
||||
# Do NOT "fix" by regenerating ids on insert: that severs drift/refresh-
|
||||
# by-id lineage (see the remapBundleUuids dead-code note + task_5200c1fc).
|
||||
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||||
if not module_id:
|
||||
return
|
||||
bucket = ctx.setdefault("__wp_picks__", {})
|
||||
if isinstance(bucket, dict):
|
||||
bucket[module_id] = {
|
||||
entry = {
|
||||
"value": chosen.get("value"),
|
||||
"sub_category": chosen.get("sub_category"),
|
||||
"sub_categories": chosen.get("sub_categories", []),
|
||||
"id": chosen.get("id"),
|
||||
# SP3: per-pick list the combine fn reads. A single pick is a
|
||||
# one-element list so the multi-tag/multi-pick applier has a
|
||||
# uniform shape regardless of single- vs multi-select source.
|
||||
"picks": [{
|
||||
"value": chosen.get("value", ""),
|
||||
"tags": list(chosen.get("sub_categories") or []),
|
||||
}],
|
||||
}
|
||||
# Additive per-instance view (task_5200c1fc). When this wildcard
|
||||
# carries a `bundle_origin`, ALSO file the pick under
|
||||
# `by_origin[origin]` so a constraint in the SAME bundle copy can
|
||||
# read its own source instance instead of the last-writer survivor
|
||||
# at the top-level key. The top-level entry above is left as the
|
||||
# fallback bucket (unchanged shape) — anything reading it is
|
||||
# unaffected. Omitted entirely when no origin is stamped.
|
||||
_file_pick_by_origin(ctx, entry, bucket, module_id)
|
||||
bucket[module_id] = entry
|
||||
|
||||
|
||||
def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) -> None:
|
||||
"""Multi-select counterpart to `_record_pick` (SP2a). Stash the joined
|
||||
value + the individual picked values + the union of their sub-categories,
|
||||
so the debug Picks view can render the list. Single-pick records (via
|
||||
`_record_pick`) keep their original shape."""
|
||||
# Same library-uuid bucket collision as `_record_pick` (see task_5200c1fc).
|
||||
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||||
if not module_id:
|
||||
return
|
||||
bucket = ctx.setdefault("__wp_picks__", {})
|
||||
if not isinstance(bucket, dict):
|
||||
return
|
||||
values = [str(c.get("value", "")) for c in chosen_list]
|
||||
union: list[str] = []
|
||||
for c in chosen_list:
|
||||
for t in (c.get("sub_categories") or []):
|
||||
if t not in union:
|
||||
union.append(t)
|
||||
# SP3: per-pick list (value + that pick's own tags, NOT the union) so the
|
||||
# combine fn can apply the matrix per pick. The union above stays for the
|
||||
# debug Picks view; `picks` is what constraint application reads.
|
||||
per_pick = [
|
||||
{"value": str(c.get("value", "")), "tags": list(c.get("sub_categories") or [])}
|
||||
for c in chosen_list
|
||||
]
|
||||
entry = {
|
||||
"value": sep.join(values),
|
||||
"values": values,
|
||||
"sub_categories": union,
|
||||
"id": chosen_list[0].get("id") if chosen_list else None,
|
||||
"picks": per_pick,
|
||||
}
|
||||
# Same per-instance view as `_record_pick` (task_5200c1fc).
|
||||
_file_pick_by_origin(ctx, entry, bucket, module_id)
|
||||
bucket[module_id] = entry
|
||||
|
||||
|
||||
class WildcardHandler(ModuleHandler):
|
||||
type_id = "wildcard"
|
||||
|
||||
@staticmethod
|
||||
def _apply_pool_filter(
|
||||
options: list[dict[str, Any]],
|
||||
expr: str,
|
||||
*,
|
||||
exclude_null: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Narrow an option pool by a boolean sub-category expression.
|
||||
|
||||
`expr` is parsed via the shared subcat_filter matcher and tested
|
||||
against each non-null option's tag set (`sub_categories`). Empty
|
||||
/ whitespace `expr` ⇒ no tag filtering. The null option
|
||||
(`is_null`) is governed solely by `exclude_null`; the expression
|
||||
never applies to it (spec §3.4).
|
||||
"""
|
||||
ast = _parse_subcat(expr) if isinstance(expr, str) else None
|
||||
out: list[dict[str, Any]] = []
|
||||
for o in options:
|
||||
if o.get("is_null"):
|
||||
if not exclude_null:
|
||||
out.append(o)
|
||||
continue
|
||||
if _subcat_matches(ast, set(o.get("sub_categories") or [])):
|
||||
out.append(o)
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
@@ -188,6 +371,56 @@ class WildcardHandler(ModuleHandler):
|
||||
options = payload.get("options")
|
||||
if not isinstance(options, list):
|
||||
raise ValueError("wildcard payload.options must be a list")
|
||||
|
||||
# Sub-category registry — the allowed tag names. Validated first
|
||||
# so each option's membership + tag_groups can be checked against
|
||||
# it. `validate_subcat_name` enforces the §3.6 name rules (single
|
||||
# token; no whitespace / boolean-grammar / ref chars; not a
|
||||
# reserved word incl. `null`).
|
||||
registry_list = payload.get("sub_categories", [])
|
||||
if not isinstance(registry_list, list):
|
||||
raise ValueError("wildcard payload.sub_categories must be a list")
|
||||
registry: set[str] = set()
|
||||
for i, sc in enumerate(registry_list):
|
||||
if not isinstance(sc, str):
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}] must be a string"
|
||||
)
|
||||
name_err = validate_subcat_name(sc)
|
||||
if name_err:
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}]: {name_err}"
|
||||
)
|
||||
registry.add(sc)
|
||||
|
||||
# Optional UI-only grouping of registry tags into named axes. The
|
||||
# engine ignores it at resolve time; validate the shape, that
|
||||
# every member is a registry tag, and that a tag lives in at most
|
||||
# one group.
|
||||
tag_groups = payload.get("tag_groups")
|
||||
if tag_groups is not None:
|
||||
if not isinstance(tag_groups, dict):
|
||||
raise ValueError("wildcard payload.tag_groups must be an object")
|
||||
seen_grouped: set[str] = set()
|
||||
for gname, members in tag_groups.items():
|
||||
if not isinstance(gname, str) or not isinstance(members, list):
|
||||
raise ValueError(
|
||||
"wildcard payload.tag_groups entries must be "
|
||||
"name -> string[]"
|
||||
)
|
||||
for mem in members:
|
||||
if mem not in registry:
|
||||
raise ValueError(
|
||||
f"wildcard payload.tag_groups[{gname!r}]: {mem!r} "
|
||||
f"not in sub_categories registry"
|
||||
)
|
||||
if mem in seen_grouped:
|
||||
raise ValueError(
|
||||
f"wildcard payload.tag_groups: {mem!r} appears in "
|
||||
f"more than one group"
|
||||
)
|
||||
seen_grouped.add(mem)
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
null_count = 0
|
||||
for i, opt in enumerate(options):
|
||||
@@ -220,12 +453,12 @@ class WildcardHandler(ModuleHandler):
|
||||
f"wildcard payload.options[{i}] null option "
|
||||
f"{opt_id!r} must have empty value (got {value!r})"
|
||||
)
|
||||
sub_null = opt.get("sub_category")
|
||||
if sub_null is not None and sub_null != "":
|
||||
null_subs = opt.get("sub_categories", [])
|
||||
if null_subs:
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}] null option "
|
||||
f"{opt_id!r} must have no sub_category (got "
|
||||
f"{sub_null!r})"
|
||||
f"{opt_id!r} must have no sub_categories (got "
|
||||
f"{null_subs!r})"
|
||||
)
|
||||
else:
|
||||
if not isinstance(value, str) or value == "":
|
||||
@@ -242,11 +475,22 @@ class WildcardHandler(ModuleHandler):
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].weight must not be negative"
|
||||
)
|
||||
sub_cat = opt.get("sub_category")
|
||||
if sub_cat is not None and not isinstance(sub_cat, str):
|
||||
subs = opt.get("sub_categories", [])
|
||||
if not isinstance(subs, list):
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].sub_category must be a string"
|
||||
f"wildcard payload.options[{i}].sub_categories must be a list"
|
||||
)
|
||||
for s in subs:
|
||||
if not isinstance(s, str):
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].sub_categories must be "
|
||||
f"strings"
|
||||
)
|
||||
if s not in registry:
|
||||
raise ValueError(
|
||||
f"wildcard payload.options[{i}].sub_categories: {s!r} "
|
||||
f"not in sub_categories registry"
|
||||
)
|
||||
if null_count > 1:
|
||||
raise ValueError(
|
||||
f"wildcard payload may have at most one null option "
|
||||
@@ -277,35 +521,6 @@ class WildcardHandler(ModuleHandler):
|
||||
raise ValueError(
|
||||
f"wildcard payload.var_binding {binding!r} is not a valid identifier"
|
||||
)
|
||||
sub_categories = payload.get("sub_categories", [])
|
||||
if not isinstance(sub_categories, list):
|
||||
raise ValueError("wildcard payload.sub_categories must be a list")
|
||||
for i, sc in enumerate(sub_categories):
|
||||
if not isinstance(sc, str):
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}] must be a string"
|
||||
)
|
||||
# `null` is a reserved keyword in the nested-ref filter
|
||||
# syntax — `@{uuid:warm,null}` opts the null option into
|
||||
# the pool. Forbid sub_category names colliding with it.
|
||||
if sc == "null":
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}] 'null' is reserved "
|
||||
f"(used by the nested ref filter syntax)"
|
||||
)
|
||||
# Sub-category name must not contain characters used by the
|
||||
# nested-ref grammar: `,` (subcat list separator), `:`
|
||||
# (subcat segment opener), `}` (ref terminator), `#` (name
|
||||
# segment opener), `@` / `{` (ref-prefix collision risk).
|
||||
# Editor + API guard against this so the regex never sees
|
||||
# ambiguous input.
|
||||
bad = set(sc) & _REF_FORBIDDEN_CHARS
|
||||
if bad:
|
||||
raise ValueError(
|
||||
f"wildcard payload.sub_categories[{i}] {sc!r} contains "
|
||||
f"forbidden characters {sorted(bad)} "
|
||||
f"(would break the @{{uuid:subcat}} syntax)"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
@@ -342,21 +557,23 @@ class WildcardHandler(ModuleHandler):
|
||||
return {binding: resolve_text(value, resolve_ctx)}
|
||||
# else: pinned target is missing — fall through to random.
|
||||
|
||||
# `category_filter` narrows the option pool to entries whose
|
||||
# `sub_category` is in the chosen list. Empty list / None = no
|
||||
# filter (all sub-categories eligible). Options with a `None`
|
||||
# / missing sub_category are excluded by an explicit filter
|
||||
# (they're "unsorted" and the user opted into a curated set) —
|
||||
# EXCEPT the null option (`is_null: True`), which is an
|
||||
# orthogonal "no-output" slot and survives every category
|
||||
# filter. See docs/superpowers/specs/2026-05-24-null-wildcard-option-design.md.
|
||||
# `category_filter` narrows the option pool to entries whose tag
|
||||
# set (`sub_categories`) satisfies a boolean expression
|
||||
# (`and`/`or`/`not`/parens; comma=or). Empty / missing = no tag
|
||||
# filter. `exclude_null` is a separate flag: when true the null
|
||||
# option is dropped, otherwise it is an orthogonal "no-output"
|
||||
# slot that survives every filter (the expression never applies
|
||||
# to it). Untagged non-null options bypass the expression unless
|
||||
# a bare tag term excludes them (spec §3.4). See the shared
|
||||
# matcher in engine/syntax/subcat_filter.py.
|
||||
category_filter = instance.get("category_filter")
|
||||
if isinstance(category_filter, list) and category_filter:
|
||||
allowed_cats = set(category_filter)
|
||||
options = [
|
||||
o for o in options
|
||||
if o.get("is_null") or o.get("sub_category") in allowed_cats
|
||||
]
|
||||
exclude_null = bool(instance.get("exclude_null", False))
|
||||
if (isinstance(category_filter, str) and category_filter.strip()) or exclude_null:
|
||||
options = cls._apply_pool_filter(
|
||||
options,
|
||||
category_filter if isinstance(category_filter, str) else "",
|
||||
exclude_null=exclude_null,
|
||||
)
|
||||
|
||||
enabled = instance.get("enabled_options")
|
||||
if enabled is not None:
|
||||
@@ -400,16 +617,22 @@ class WildcardHandler(ModuleHandler):
|
||||
if my_id:
|
||||
constraints = ctx.get("__wp_constraints__") if ctx is not None else None
|
||||
picks = ctx.get("__wp_picks__") if ctx is not None else None
|
||||
# First-instance one-shot: pass the ctx-resident consumed
|
||||
# set so the apply_constraints helper marks fired
|
||||
# constraints + skips them on subsequent target instances.
|
||||
consumed = (
|
||||
ctx.setdefault("__wp_consumed_constraints__", set())
|
||||
# SP3 reach selector: thread the ctx-resident per-constraint
|
||||
# hit counter so each covering constraint re-weights this
|
||||
# firing target instance (no more one-shot / consumed-set).
|
||||
# `firing_uid` identifies THIS direct top-level instance for
|
||||
# the `pick` selector's occurrence match.
|
||||
hits = (
|
||||
ctx.setdefault("__wp_constraint_hits__", {})
|
||||
if isinstance(ctx, dict) else {}
|
||||
)
|
||||
firing_uid = (
|
||||
ctx.get("__wp_current_module_uid__")
|
||||
if isinstance(ctx, dict) else None
|
||||
)
|
||||
options, any_constraint_applied = apply_constraints_for_target(
|
||||
options, my_id, constraints, picks, ctx["__wp_warnings__"],
|
||||
consumed=consumed,
|
||||
hits=hits, firing_uid=firing_uid,
|
||||
)
|
||||
if any_constraint_applied:
|
||||
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
|
||||
@@ -422,7 +645,17 @@ class WildcardHandler(ModuleHandler):
|
||||
# unlocked pick exactly. This is the contract the lock UX
|
||||
# relies on: the user sees a roll, locks it, captures the
|
||||
# current chain seed, future runs reproduce the same roll.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse the frame-0 fully-resolved value verbatim (from the
|
||||
# pipeline's base pass). Skips the pick AND nested-ref resolution,
|
||||
# so a constrained or @{}-bearing wildcard stays frozen across the
|
||||
# whole run — "green jeans" on frame 1 stays "green jeans".
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict) and binding in _hb:
|
||||
return {binding: _hb[binding]}
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if locked_seed is not None:
|
||||
try:
|
||||
@@ -434,6 +667,48 @@ class WildcardHandler(ModuleHandler):
|
||||
effective_seed = chain_seed
|
||||
rng = _derive_module_rng(effective_seed, binding)
|
||||
|
||||
# SP2a multi-select: a count range other than (1, 1) picks N options
|
||||
# without replacement and binds a ListVar. The null option is never in
|
||||
# the multi-pick pool (exclude_null is single-pick-only); "maybe
|
||||
# nothing" is min=0. (1, 1) falls through to the single-pick path
|
||||
# below unchanged — same RNG draws, so existing locked seeds reproduce.
|
||||
raw_min = _coerce_pick_int(instance.get("pick_min"), 1)
|
||||
raw_max = _coerce_pick_int(instance.get("pick_max"), raw_min)
|
||||
lo = max(0, min(raw_min, raw_max))
|
||||
hi = max(lo, raw_max)
|
||||
if not (lo == 1 and hi == 1):
|
||||
pool = [o for o in options if not o.get("is_null")]
|
||||
weights = [max(0.0, float(o.get("weight", 1))) for o in pool]
|
||||
pool_n = len(pool)
|
||||
independent = bool(instance.get("pick_independent", False))
|
||||
if independent:
|
||||
# SP2c: independent multi-pick draws WITH replacement (repeats
|
||||
# allowed), mirroring the inline `~` flag — so the count is the
|
||||
# requested range and is NOT clamped to the pool size.
|
||||
count = lo if lo == hi else lo + int(rng.random() * (hi - lo + 1))
|
||||
picks = [
|
||||
p for p in (_pick_weighted(pool, rng) for _ in range(count))
|
||||
if p is not None
|
||||
]
|
||||
else:
|
||||
lo_c, hi_c = min(lo, pool_n), min(hi, pool_n)
|
||||
count = lo_c if lo_c == hi_c else lo_c + int(rng.random() * (hi_c - lo_c + 1))
|
||||
picks = pick_k_unique(pool, weights, count, rng)
|
||||
sep = instance.get("pick_separator")
|
||||
if not isinstance(sep, str):
|
||||
sep = ", "
|
||||
items: list[str] = []
|
||||
saved_rng_multi = ctx.get("__wp_rng__")
|
||||
ctx["__wp_rng__"] = rng
|
||||
try:
|
||||
multi_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||||
for opt in picks:
|
||||
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
|
||||
finally:
|
||||
ctx["__wp_rng__"] = saved_rng_multi
|
||||
_record_pick_multi(ctx, picks, sep)
|
||||
return {binding: ListVar(items, sep)}
|
||||
|
||||
chosen = _pick_weighted(options, rng)
|
||||
if chosen is None:
|
||||
return {binding: ""}
|
||||
@@ -444,39 +719,9 @@ class WildcardHandler(ModuleHandler):
|
||||
# constraint exception keys on the literal empty pick value).
|
||||
_record_pick(ctx, chosen)
|
||||
|
||||
# First-instance positional-claim failsafe. This wildcard may
|
||||
# CARRY a constraint's target via an `@{target}` ref in one of
|
||||
# its options. The pair badge already assigned the constraint to
|
||||
# this carrier, so once the carrier ROLLS — regardless of which
|
||||
# option won, including the `null` / empty-value option — it
|
||||
# claims the constraint so it doesn't spill onto a later target
|
||||
# instance. `claim_carrier_constraints` is a no-op when the
|
||||
# chosen option DID resolve the ref (the nested-resolve path
|
||||
# below already consumed it) or the source isn't picked yet.
|
||||
#
|
||||
# CRITICAL: must fire on EVERY roll path, including the
|
||||
# empty-value early return — a backdrop that rolls its `null`
|
||||
# option still "rolled" and must still claim its carried
|
||||
# constraints. The 2026-05-26 bug was calling this only after
|
||||
# resolve_text, so a null/empty pick skipped the claim and the
|
||||
# constraint either spilled or surfaced a misleading
|
||||
# never_applied warning.
|
||||
def _claim_carried() -> None:
|
||||
if ctx is None:
|
||||
return
|
||||
from engine.modules._constraints import claim_carrier_constraints
|
||||
claim_carrier_constraints(
|
||||
options,
|
||||
ctx.get("__wp_constraints__"),
|
||||
ctx.get("__wp_picks__"),
|
||||
ctx.get("__wp_consumed_constraints__"),
|
||||
)
|
||||
|
||||
value = str(chosen.get("value", ""))
|
||||
if not value:
|
||||
# Empty / null pick — no ref to resolve, but the carrier
|
||||
# still rolled, so claim before returning.
|
||||
_claim_carried()
|
||||
# Empty / null pick — no ref to resolve.
|
||||
return {binding: ""}
|
||||
|
||||
# Swap `ctx['__wp_rng__']` to the per-module rng for the
|
||||
@@ -495,12 +740,22 @@ class WildcardHandler(ModuleHandler):
|
||||
ctx["__wp_rng__"] = rng
|
||||
try:
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||||
# SP3 nested-pick reach: THIS wildcard is the carrier of any
|
||||
# `@{T}` ref inside `value`. Stamp (its per-instance uid, the
|
||||
# chosen option's id) on the resolve frame so a constraint on
|
||||
# T whose `pick` selector lists this (carrier_uid, option_id)
|
||||
# nested occurrence matches. A `pick` keyed on a DEEPER
|
||||
# carrier won't match this top-level uid — that's by design
|
||||
# (only one-hop carriers are individually pickable). Set on
|
||||
# the freshly-built ctx (carrier defaults None); no restore
|
||||
# needed — resolve_ctx is scoped to this resolve_text call.
|
||||
set_carrier = getattr(resolve_ctx, "set_carrier", None)
|
||||
if callable(set_carrier):
|
||||
set_carrier(
|
||||
ctx.get("__wp_current_module_uid__"), chosen.get("id"),
|
||||
)
|
||||
resolved = resolve_text(value, resolve_ctx)
|
||||
finally:
|
||||
ctx["__wp_rng__"] = saved_rng
|
||||
|
||||
# Claim AFTER resolve_text so a chosen option that DID carry the
|
||||
# ref gets consumed-with-effect by the nested-resolve path
|
||||
# first; the claim then no-ops on it.
|
||||
_claim_carried()
|
||||
return {binding: resolved}
|
||||
|
||||
+301
-42
@@ -20,11 +20,14 @@ from engine.modules.snapshot import coerce_legacy_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# `@{uuid}` ref matcher — mirror of engine.syntax.tokenize._REF_RE.
|
||||
# Used by the never_applied warning classifier to tell "target has a
|
||||
# carrier/instance somewhere in the chain" apart from "target uuid is
|
||||
# nowhere to be found".
|
||||
_PIPELINE_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}")
|
||||
# `@{uuid}` ref matcher — mirror of engine.syntax.tokenize._REF_RE
|
||||
# (4-segment: uuid + optional #name / :expr / !null; only the uuid is
|
||||
# captured here). Used by the never_applied warning classifier to tell
|
||||
# "target has a carrier/instance somewhere in the chain" apart from
|
||||
# "target uuid is nowhere to be found".
|
||||
_PIPELINE_REF_RE = re.compile(
|
||||
r"@\{([0-9a-f]{8})(?:#[^#:}@{!]*)?(?::[^}!]*)?(?:![^}]*)?\}"
|
||||
)
|
||||
|
||||
|
||||
def _module_field(module: Any, key: str) -> Any:
|
||||
@@ -233,6 +236,18 @@ def _extract_static_meta(
|
||||
return meta
|
||||
|
||||
|
||||
def _module_seed_scope(module: object) -> str | None:
|
||||
"""Read a module's base ``instance.seed_scope`` ("hold"/"vary"/None).
|
||||
Works for both dict and object module shapes. seed_scope is a base-only
|
||||
setting (disabled in per-frame edit mode), so the base instance is
|
||||
authoritative."""
|
||||
inst = module.get("instance") if isinstance(module, dict) else getattr(module, "instance", None)
|
||||
if isinstance(inst, dict):
|
||||
s = inst.get("seed_scope")
|
||||
return s if isinstance(s, str) else None
|
||||
return None
|
||||
|
||||
|
||||
class PipelineEngine:
|
||||
"""Runs an ordered list of modules against a context dict."""
|
||||
|
||||
@@ -241,18 +256,84 @@ class PipelineEngine:
|
||||
modules: list[Module],
|
||||
ctx: Context | None = None,
|
||||
seed: int = 0,
|
||||
hold_seed: int | None = None,
|
||||
loop_index: int = 0,
|
||||
) -> Context:
|
||||
"""Execute modules top-to-bottom, mutating ctx, returning ctx."""
|
||||
ctx = {} if ctx is None else ctx
|
||||
ctx["__wp_node_seed__"] = seed
|
||||
ctx["__wp_node_seed_hold__"] = hold_seed if hold_seed is not None else seed
|
||||
ctx["__wp_loop_index__"] = loop_index
|
||||
ctx["__wp_rng__"] = random.Random(seed)
|
||||
ctx.setdefault("__wp_warnings__", [])
|
||||
ctx.setdefault("__wp_trace__", [])
|
||||
ctx.setdefault("__wp_internal_flags__", {})
|
||||
# Tracks constraint module ids that have already fired against
|
||||
# their first downstream target instance. See
|
||||
# docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
|
||||
ctx.setdefault("__wp_consumed_constraints__", set())
|
||||
# SP3 reach selector: per-constraint firing count keyed by
|
||||
# `__constraint_module_id__`. Drives first/next/all/pick
|
||||
# coverage in apply_constraints_for_target + the never_applied /
|
||||
# partial_reach finalisation below. Replaces the pre-SP3
|
||||
# one-shot consumed-set.
|
||||
ctx.setdefault("__wp_constraint_hits__", {})
|
||||
|
||||
# "Hold the value" base pass. A module on seed_scope=hold must resolve
|
||||
# to its frame-0 (loop_index=0) value on EVERY iteration — INCLUDING
|
||||
# nested @{} refs, which resolve_text bakes into the stored value.
|
||||
# Re-resolving each iteration (the old seed-swap) failed for
|
||||
# constrained wildcards (the constraint reshapes the pool per
|
||||
# iteration) and for nested refs. So when a loop is driving
|
||||
# (loop_index != 0) and any module holds, resolve the chain ONCE at
|
||||
# loop_index=0 with the constant hold seed; held handlers copy their
|
||||
# resolved value from this base context verbatim. The deepcopy keeps
|
||||
# the throwaway pass's warnings/trace/constraint-state off the real
|
||||
# ctx. loop_index=0 here means the recursive run skips its own base
|
||||
# pass — no infinite recursion.
|
||||
_any_hold = any(_module_seed_scope(m) == "hold" for m in modules)
|
||||
if loop_index != 0 and _any_hold:
|
||||
import copy as _copy
|
||||
|
||||
_hb = int(ctx.get("__wp_node_seed_hold__", 0) or 0)
|
||||
base_ctx = PipelineEngine().run(
|
||||
modules,
|
||||
ctx=_copy.deepcopy(ctx),
|
||||
seed=_hb,
|
||||
hold_seed=_hb,
|
||||
loop_index=0,
|
||||
)
|
||||
ctx["__wp_hold_base_ctx__"] = base_ctx
|
||||
|
||||
# Held wildcards copy their frozen VALUE from the base pass but
|
||||
# early-return before `_record_pick` (wildcard_handler), so their
|
||||
# frame-0 pick never lands in the real ctx. Downstream constraints
|
||||
# then can't find the held source — they mis-fire (spurious
|
||||
# source_missing / never_applied warnings) and, cross-node, leave a
|
||||
# held target UNCONSTRAINED (the held-outfit drift: kimono at
|
||||
# frame 0, unconstrained pick on frames 2+). Carry the base pass's
|
||||
# pick + hit state for held modules into the real ctx HERE, before
|
||||
# the per-module loop, so the registration is present for this
|
||||
# node's constraints AND flows downstream (via __wp_picks__ /
|
||||
# __wp_constraints__ carry-over) into the next node's base pass.
|
||||
# The value-freeze path in the wildcard handler is untouched.
|
||||
_base_picks = base_ctx.get("__wp_picks__")
|
||||
if isinstance(_base_picks, dict):
|
||||
_real_picks = ctx.setdefault("__wp_picks__", {})
|
||||
if isinstance(_real_picks, dict):
|
||||
for _m in modules:
|
||||
if _module_seed_scope(_m) != "hold":
|
||||
continue
|
||||
_mid = (
|
||||
_m.get("id", "") if isinstance(_m, dict)
|
||||
else getattr(_m, "id", "")
|
||||
)
|
||||
if _mid and _mid in _base_picks and _mid not in _real_picks:
|
||||
_real_picks[_mid] = _base_picks[_mid]
|
||||
_base_hits = base_ctx.get("__wp_constraint_hits__")
|
||||
if isinstance(_base_hits, dict):
|
||||
_real_hits = ctx.setdefault("__wp_constraint_hits__", {})
|
||||
if isinstance(_real_hits, dict):
|
||||
for _cid, _n in _base_hits.items():
|
||||
_real_hits[_cid] = max(
|
||||
int(_real_hits.get(_cid, 0) or 0), int(_n or 0)
|
||||
)
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
# Normalise id/type/enabled reads to work for both dicts and objects.
|
||||
@@ -275,25 +356,68 @@ class PipelineEngine:
|
||||
# imported from someone else's machine), letting WP_Debug
|
||||
# render a readable chip instead of a raw uuid.
|
||||
_module_name = module.get("name", "") or ""
|
||||
# Per-insertion bundle discriminator. Two instances of one
|
||||
# library wildcard (a bundle inserted twice) carry DISTINCT
|
||||
# `bundle_origin` values (insert.ts stamps each child with
|
||||
# its immediate BundleInstance._uid). Threaded into ctx so a
|
||||
# constraint can bind to the source pick from its OWN bundle
|
||||
# copy (task_5200c1fc). Empty string when the module has no
|
||||
# bundle origin (top-level / manually-added / legacy).
|
||||
_module_bundle_origin = module.get("bundle_origin", "") or ""
|
||||
else:
|
||||
_module_id = getattr(module, "id", "")
|
||||
_module_uid = getattr(module, "_uid", "") or ""
|
||||
_module_type_raw = getattr(module, "type", None) or ""
|
||||
_module_enabled = getattr(module, "enabled", True)
|
||||
_module_name = getattr(module, "name", "") or ""
|
||||
_module_bundle_origin = getattr(module, "bundle_origin", "") or ""
|
||||
|
||||
if not _module_enabled:
|
||||
# Disabled modules don't run — but the trace row still
|
||||
# surfaces what the module would have written, so the
|
||||
# debug viewer can label it `$varname (disabled)`
|
||||
# instead of falling back to an opaque short-uuid.
|
||||
_k = int(ctx.get("__wp_loop_index__", 0))
|
||||
# Per-frame enable override. `frame_enabled[str(k)]` overrides the
|
||||
# base `enabled` flag for frame k in EITHER direction: a base-off
|
||||
# module can be turned ON for select frames, a base-on module OFF
|
||||
# for others. effective = frame_enabled.get(k, base_enabled).
|
||||
# Legacy `disabled_frames` (a one-way blocklist) is still honoured —
|
||||
# each listed frame reads as frame_enabled[k]=False.
|
||||
_frame_enabled_map = (
|
||||
module.get("frame_enabled")
|
||||
if isinstance(module, dict)
|
||||
else getattr(module, "frame_enabled", None)
|
||||
)
|
||||
_legacy_disabled = (
|
||||
module.get("disabled_frames")
|
||||
if isinstance(module, dict)
|
||||
else getattr(module, "disabled_frames", None)
|
||||
)
|
||||
_frame_override: bool | None = None
|
||||
if isinstance(_frame_enabled_map, dict) and str(_k) in _frame_enabled_map:
|
||||
_frame_override = bool(_frame_enabled_map[str(_k)])
|
||||
elif isinstance(_legacy_disabled, (list, tuple)) and _k in {
|
||||
int(f)
|
||||
for f in _legacy_disabled
|
||||
if isinstance(f, (int, float)) and not isinstance(f, bool)
|
||||
}:
|
||||
_frame_override = False
|
||||
_effective_enabled = (
|
||||
_frame_override if _frame_override is not None else bool(_module_enabled)
|
||||
)
|
||||
|
||||
if not _effective_enabled:
|
||||
# Module is suppressed this frame. Distinguish a per-frame
|
||||
# disable (a frame override forced it off) from a global
|
||||
# disable (base off, no frame override) so the debug viewer
|
||||
# can label `skipped_frame` vs `skipped_disabled`. Either way
|
||||
# the trace row still surfaces what it WOULD have written so
|
||||
# the debug viewer can label `$varname (disabled)` rather than
|
||||
# falling back to an opaque short-uuid.
|
||||
_frame_forced_off = _frame_override is False
|
||||
meta = _extract_static_meta(module)
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": _module_id,
|
||||
"_uid": _module_uid,
|
||||
"type": _module_type_raw,
|
||||
"enabled": False,
|
||||
"status": "skipped_disabled",
|
||||
"enabled": bool(_module_enabled) if _frame_forced_off else False,
|
||||
"status": "skipped_frame" if _frame_forced_off else "skipped_disabled",
|
||||
"writes": [],
|
||||
"error": None,
|
||||
**meta,
|
||||
@@ -311,17 +435,38 @@ class PipelineEngine:
|
||||
else:
|
||||
# Generic object: pull known snapshot keys via getattr so
|
||||
# class-level attributes (common in tests) are visible.
|
||||
# `iteration_overrides` is load-bearing — the per-frame
|
||||
# override patch below reads it back off `raw`. `disabled_frames`
|
||||
# is listed for snapshot honesty only (the skip gate already
|
||||
# read it module-direct above).
|
||||
raw = {
|
||||
k: getattr(module, k)
|
||||
for k in (
|
||||
"id", "type", "enabled", "payload", "entries",
|
||||
"meta", "library_id", "library_snapshot_at",
|
||||
"library_version_at_snapshot", "name", "category_id",
|
||||
"instance",
|
||||
"instance", "iteration_overrides",
|
||||
"disabled_frames", "frame_enabled",
|
||||
)
|
||||
if hasattr(module, k)
|
||||
}
|
||||
snapshot = coerce_legacy_module(raw)
|
||||
# Per-frame iteration override: patch snapshot["instance"]
|
||||
# for the current loop index BEFORE the handler runs.
|
||||
# iteration_overrides is a dict keyed by stringified index;
|
||||
# the matching partial dict is shallow-merged into instance.
|
||||
_ov = (
|
||||
raw.get("iteration_overrides")
|
||||
if isinstance(raw, dict)
|
||||
else getattr(module, "iteration_overrides", None)
|
||||
)
|
||||
if isinstance(_ov, dict):
|
||||
_frame_key = str(ctx.get("__wp_loop_index__", 0))
|
||||
_patch = _ov.get(_frame_key)
|
||||
if isinstance(_patch, dict) and isinstance(
|
||||
snapshot.get("instance"), dict
|
||||
):
|
||||
snapshot["instance"] = {**snapshot["instance"], **_patch}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to coerce module %r at index %s: %s", module, index, e
|
||||
@@ -351,15 +496,24 @@ class PipelineEngine:
|
||||
# wildcard handler matches THIS against a constraint's
|
||||
# `target_wildcard_id` (which is a library uuid).
|
||||
# - `__wp_current_module_uid__` = per-instance uid (`_uid`).
|
||||
# The constraint handler keys its consumed-set entry on
|
||||
# THIS so two instances of the same library constraint
|
||||
# entry are independent one-shots (per CLAUDE.md: "author
|
||||
# multiple constraint modules" to affect multiple
|
||||
# targets). Falls back to the library id when `_uid` is
|
||||
# absent (legacy / hand-built test modules).
|
||||
# This is the BARE per-instance uid (no node prefix). The
|
||||
# constraint handler keys its `__constraint_module_id__`
|
||||
# (the SP3 per-constraint hit counter) on THIS so two
|
||||
# instances of the same library constraint entry reach
|
||||
# INDEPENDENTLY (per CLAUDE.md: "author multiple constraint
|
||||
# modules" to affect multiple targets). It's ALSO the
|
||||
# `firing_uid` a direct `pick` selector matches against —
|
||||
# so a persisted `pick` must carry the bare `_uid`, not the
|
||||
# UI's badge rowKey (`${nodeId}#${_uid}`). Falls back to the
|
||||
# library id when `_uid` is absent (legacy / test modules).
|
||||
ctx["__wp_current_module_id__"] = _module_id
|
||||
ctx["__wp_current_module_uid__"] = _module_uid or _module_id
|
||||
ctx["__wp_current_module_name__"] = _module_name
|
||||
# See the `_module_bundle_origin` derivation above — exposed to
|
||||
# handlers (wildcard_handler records it on the pick; constraint_
|
||||
# handler captures it on the registered meta) so source-instance
|
||||
# binding can match a constraint to its own bundle copy's pick.
|
||||
ctx["__wp_current_module_bundle_origin__"] = _module_bundle_origin
|
||||
meta = _extract_static_meta(module)
|
||||
try:
|
||||
bindings = resolve_module(snapshot, ctx)
|
||||
@@ -466,20 +620,22 @@ class PipelineEngine:
|
||||
ctx.pop("__wp_current_module_id__", None)
|
||||
ctx.pop("__wp_current_module_uid__", None)
|
||||
ctx.pop("__wp_current_module_name__", None)
|
||||
ctx.pop("__wp_current_module_bundle_origin__", None)
|
||||
|
||||
# First-instance one-shot semantic: emit a soft (info) warning
|
||||
# for every registered constraint whose target instance never
|
||||
# came up during the chain. Surfaces in WP_Debug so the user
|
||||
# sees "you put a constraint here but nothing downstream took
|
||||
# it" — the chain still succeeds.
|
||||
# See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
|
||||
# SP3 reach selector finalisation: emit `constraint_never_applied`
|
||||
# for every registered constraint whose selector covered ZERO
|
||||
# firing target instances (hit count 0). Surfaces in WP_Debug so
|
||||
# the user sees "you put a constraint here but nothing downstream
|
||||
# took it" — the chain still succeeds. The `partial_reach` pass
|
||||
# below covers constraints that DID fire but reached fewer
|
||||
# instances than a `next N` / `pick` selector asked for.
|
||||
#
|
||||
# Dedup by cid — sibling instances of the same library entry
|
||||
# share `__constraint_module_id__`, so they show up as two
|
||||
# entries in the bucket. Without the dedup the user sees the
|
||||
# same warning twice for the same logical constraint (2026-05-26).
|
||||
constraints_bucket = ctx.get("__wp_constraints__") or []
|
||||
consumed_set = ctx.get("__wp_consumed_constraints__") or set()
|
||||
hits = ctx.get("__wp_constraint_hits__") or {}
|
||||
catalog = ctx.get("__wp_catalog__")
|
||||
warned_cids: set[str] = set()
|
||||
if isinstance(constraints_bucket, list):
|
||||
@@ -487,7 +643,7 @@ class PipelineEngine:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
if not cid or cid in consumed_set:
|
||||
if not cid or hits.get(cid, 0) != 0:
|
||||
continue
|
||||
if cid in warned_cids:
|
||||
continue
|
||||
@@ -523,24 +679,22 @@ class PipelineEngine:
|
||||
cid_suffix = _build_ref_name_suffix(c.get("__constraint_library_name__"))
|
||||
tgt_suffix = _build_ref_name_suffix(target_name)
|
||||
# Classify the cause so the message is actionable.
|
||||
# With the carrier-claim failsafe in place, a carrier
|
||||
# that rolls ANY option claims its constraint — so an
|
||||
# unconsumed constraint whose target IS present in the
|
||||
# chain means the target instance/carrier ran BEFORE
|
||||
# this constraint registered (ordering), or sits in a
|
||||
# Reach is downstream-relative: a constraint only applies
|
||||
# to target occurrences that resolve AFTER it registers.
|
||||
# So a never-applied constraint whose target IS present in
|
||||
# the chain means every occurrence ran BEFORE this
|
||||
# constraint registered (ordering), or sits in a
|
||||
# branch/Context this run didn't reach. Absent target =
|
||||
# a wrong/stale uuid.
|
||||
present = _target_present_in_chain(
|
||||
target_uuid, modules, catalog,
|
||||
)
|
||||
if present:
|
||||
# Target IS in the chain. With the carrier-claim
|
||||
# failsafe, any carrier/instance that ROLLS after the
|
||||
# constraint registers claims it — so reaching this
|
||||
# branch means the target's only appearance(s) ran
|
||||
# Target IS in the chain but the selector covered no
|
||||
# firing occurrence — every appearance resolved
|
||||
# BEFORE this constraint registered (e.g. a source
|
||||
# wildcard that nests its own @{target}), or live in
|
||||
# a branch / Context this run didn't execute, or are
|
||||
# wildcard that nests its own @{target}), or lives in
|
||||
# a branch / Context this run didn't execute, or is
|
||||
# disabled. Deliberately NOT prescribing "move the
|
||||
# constraint up" — that's wrong when the target's
|
||||
# carrier is also the constraint's source (the source
|
||||
@@ -590,5 +744,110 @@ class PipelineEngine:
|
||||
# otherwise render as plain text).
|
||||
"message": message,
|
||||
})
|
||||
# Source-side companion warning. The `never_applied` message
|
||||
# above is target-focused; when the constraint's SOURCE
|
||||
# wildcard is ALSO absent from the chain, surface that
|
||||
# distinctly so the Debug viewer shows BOTH ends (mirrors the
|
||||
# editor's separate "source missing" / "target missing" row
|
||||
# badges). A constraint registers by its own module position,
|
||||
# so it can sit in the bucket with neither end present.
|
||||
source_uuid = c.get("source_wildcard_id", "")
|
||||
if source_uuid and not _target_present_in_chain(
|
||||
source_uuid, modules, catalog,
|
||||
):
|
||||
src_row = (
|
||||
catalog.get(source_uuid) if isinstance(catalog, dict) else None
|
||||
)
|
||||
source_name = (
|
||||
(src_row.get("name", "") or "")
|
||||
if isinstance(src_row, dict) else ""
|
||||
)
|
||||
src_suffix = _build_ref_name_suffix(source_name)
|
||||
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
|
||||
if isinstance(warnings_bucket, list):
|
||||
warnings_bucket.append({
|
||||
"type": "constraint_source_missing",
|
||||
"severity": "warn",
|
||||
"module_id": cid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"constraint_id": cid,
|
||||
"source_wildcard_id": source_uuid,
|
||||
},
|
||||
"message": (
|
||||
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
|
||||
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
|
||||
f"this chain, so it never registers a source pick."
|
||||
),
|
||||
})
|
||||
|
||||
# SP3 partial-reach finalisation: a constraint that DID fire but
|
||||
# whose `next N` / `pick` selector matched FEWER occurrences than
|
||||
# requested. Distinct from never_applied (which fired zero times)
|
||||
# — this is "the selector asked for more than the chain offered
|
||||
# downstream". `all` / `first` can't be partial. Deduped by cid,
|
||||
# same as never_applied.
|
||||
partial_warned: set[str] = set()
|
||||
if isinstance(constraints_bucket, list):
|
||||
for c in constraints_bucket:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
cid = c.get("__constraint_module_id__")
|
||||
seen = hits.get(cid, 0) if cid else 0
|
||||
if not cid or seen == 0 or cid in partial_warned:
|
||||
continue
|
||||
sel = c.get("target_select") or {"mode": "all"}
|
||||
mode = sel.get("mode", "all")
|
||||
if mode == "next":
|
||||
try:
|
||||
want = int(sel.get("count", 1))
|
||||
except (TypeError, ValueError):
|
||||
want = 1
|
||||
elif mode == "pick":
|
||||
want = len(sel.get("picks") or [])
|
||||
else:
|
||||
continue
|
||||
# LIMITATION (holistic-review L1): `seen` is the per-cid
|
||||
# TOTAL target-encounter count (`hits[cid]`, bumped on
|
||||
# every firing in apply_constraints_for_target), NOT the
|
||||
# count of picks that actually MATCHED. So a `pick`
|
||||
# partial signal is imprecise: it compares encounters to
|
||||
# `len(picks)` rather than matched-vs-listed. A precise
|
||||
# count would need a separate ctx-threaded matched-pick
|
||||
# counter (new bookkeeping across the _constraints apply
|
||||
# boundary) — deferred to avoid over-plumbing. `next` is
|
||||
# exact (it covers the first N encounters by construction).
|
||||
if seen >= want:
|
||||
continue
|
||||
partial_warned.add(cid)
|
||||
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
|
||||
if isinstance(warnings_bucket, list):
|
||||
warnings_bucket.append({
|
||||
# Spec: partial reach is INFO, not warn — the
|
||||
# selector simply found fewer downstream targets
|
||||
# than requested this run (not an error condition).
|
||||
# `constraint_never_applied` stays `warn` (a
|
||||
# constraint that fired zero times is worth a louder
|
||||
# signal). DebugViewer maps `info` → blue/accent.
|
||||
"type": "constraint_partial_reach",
|
||||
"severity": "info",
|
||||
"module_id": cid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"constraint_id": cid,
|
||||
"target_wildcard_id": c.get("target_wildcard_id"),
|
||||
"requested": want,
|
||||
"reached": seen,
|
||||
},
|
||||
"message": (
|
||||
f"constraint reach selector ({mode}) asked for "
|
||||
f"{want} target instance(s) but only {seen} "
|
||||
f"resolved downstream this run."
|
||||
),
|
||||
})
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -14,6 +14,9 @@ from typing import Any, Literal
|
||||
InstanceFieldType = Literal[
|
||||
"string", "list[string]", "dict[string,number]", "dict[string,dict]",
|
||||
"dict[string,string]",
|
||||
# Generic object — used by SP3 `target_select`, whose interior shape is
|
||||
# validated by the constraint handler rather than this type-only schema.
|
||||
"dict",
|
||||
"boolean", "number", "list[dict]",
|
||||
]
|
||||
|
||||
@@ -22,9 +25,32 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"variable_binding": "string",
|
||||
"enabled_options": "list[string]",
|
||||
"option_weights": "dict[string,number]",
|
||||
"category_filter": "list[string]",
|
||||
# `category_filter` is a boolean sub-category expression string
|
||||
# (SP1). The separate `exclude_null` instance flag is declared
|
||||
# here + in the TS `_shell.ts` twin together in the SP1 UI chunk
|
||||
# (keeps the parity guard green); the engine resolver already
|
||||
# reads it directly.
|
||||
"category_filter": "string",
|
||||
"exclude_null": "boolean",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: "hold" pins RNG to the run base seed (same
|
||||
# across loop iterations, re-rolls per run); "vary" (default/absent)
|
||||
# rides the per-iteration seed. `locked_seed` takes priority when set.
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
# SP2a multi-select: per-instance count range (pick_min..pick_max; a
|
||||
# range other than 1..1 resolves N options without replacement into a
|
||||
# list-valued variable) + the separator used to join it for a bare
|
||||
# `$var`. Instance-local: NOT part of a standalone module's published
|
||||
# payload — but bundle children freeze the whole instance as a
|
||||
# snapshot, so a published bundle DOES carry them (intentional
|
||||
# frozen-snapshot fidelity).
|
||||
"pick_min": "number",
|
||||
"pick_max": "number",
|
||||
"pick_separator": "string",
|
||||
# SP2c: multi-pick draws WITH replacement (repeats allowed) when true,
|
||||
# without (unique) when false/absent. Mirrors the inline `~` flag.
|
||||
"pick_independent": "boolean",
|
||||
# `mode` and `pinned_option_id` removed in v2 — resolve mode is
|
||||
# implicit in pool state. Engine handler still reads them when
|
||||
# present in legacy snapshots; the schema validator now flags
|
||||
@@ -37,6 +63,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
# resolution gives fixed_values seed-lock parity with wildcard
|
||||
# + combine. Locked seed pins the alternation outcome.
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
},
|
||||
"combine": {
|
||||
# v2 single-pane modal exposes a richer override surface:
|
||||
@@ -47,6 +75,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"template_override": "string",
|
||||
"variable_binding": "string",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
},
|
||||
"derivation": {
|
||||
@@ -86,6 +116,9 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"exception_mode_overrides": "dict[string,string]",
|
||||
"exception_factor_overrides": "dict[string,number]",
|
||||
"extra_exceptions": "list[dict]",
|
||||
# SP3 reach selector override. Interior shape (mode/count/picks) is
|
||||
# validated by ConstraintHandler; here it is type-only ("is a dict").
|
||||
"target_select": "dict",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -101,6 +134,8 @@ def _matches_type(value: Any, spec: InstanceFieldType) -> bool:
|
||||
return isinstance(value, list) and all(isinstance(x, str) for x in value)
|
||||
if spec == "list[dict]":
|
||||
return isinstance(value, list) and all(isinstance(x, dict) for x in value)
|
||||
if spec == "dict":
|
||||
return isinstance(value, dict)
|
||||
if spec == "dict[string,number]":
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
@@ -140,4 +175,17 @@ def validate_instance(kind: str, instance: dict[str, Any]) -> list[str]:
|
||||
warnings.append(
|
||||
f"{kind}.{field} type mismatch: expected {spec}, got {type(value).__name__}"
|
||||
)
|
||||
# SP2a advisory soft-warns for the pick range (not type errors).
|
||||
if kind == "wildcard":
|
||||
pmin = instance.get("pick_min")
|
||||
pmax = instance.get("pick_max")
|
||||
nums = (int, float)
|
||||
if (isinstance(pmin, nums) and not isinstance(pmin, bool)
|
||||
and isinstance(pmax, nums) and not isinstance(pmax, bool)):
|
||||
if pmin == 0 and pmax == 0:
|
||||
warnings.append("wildcard pick range 0..0 — always empty (resolves to nothing)")
|
||||
elif pmax < pmin:
|
||||
warnings.append(
|
||||
f"wildcard.pick_max ({pmax}) is below pick_min ({pmin}) — clamped to pick_max"
|
||||
)
|
||||
return warnings
|
||||
|
||||
+23
-3
@@ -41,6 +41,19 @@ def derive_loop_seeds(base: int, count: int, strategy: str) -> list[int]:
|
||||
raise ValueError(f"unknown loop seed strategy: {strategy!r}")
|
||||
|
||||
|
||||
def apply_seed_locks(derived: list[int], locks: dict[int, int]) -> list[int]:
|
||||
"""Overlay per-index locked seeds on a derived series.
|
||||
|
||||
`locks` maps a 0-based iteration index to a pinned seed. Locked
|
||||
indices replace the derived value (masked to MAX_SAFE_SEED);
|
||||
out-of-range indices are ignored. Pure — no ComfyUI imports.
|
||||
"""
|
||||
return [
|
||||
(locks[i] & MAX_SAFE_SEED) if i in locks else seed
|
||||
for i, seed in enumerate(derived)
|
||||
]
|
||||
|
||||
|
||||
def effective_chain_seed(
|
||||
*,
|
||||
widget_seed: int,
|
||||
@@ -53,8 +66,10 @@ def effective_chain_seed(
|
||||
1. ``seed_override`` (from ContextLoop) replaces ``widget_seed`` as
|
||||
base when present.
|
||||
2. Otherwise ``widget_seed`` is the base.
|
||||
3. ``loop_index`` is mixed via XOR with a stable hash. ``loop_index=0``
|
||||
→ no-op (backwards-compat for chains without ContextLoop).
|
||||
3. ``loop_index`` is mixed via XOR with a stable hash ONLY when there is
|
||||
no override (to vary a constant ``widget_seed`` per iteration). An
|
||||
override is already per-iteration (the ContextLoop derived series) so
|
||||
it is used as-is. ``loop_index=0`` → no-op.
|
||||
|
||||
Locked-module precedence lives in the per-module handlers, NOT here.
|
||||
Locked modules read ``instance.locked_seed`` directly and ignore the
|
||||
@@ -64,7 +79,12 @@ def effective_chain_seed(
|
||||
matches the visual shape of ComfyUI's frontend-randomised seeds.
|
||||
"""
|
||||
base = seed_override if seed_override is not None else widget_seed
|
||||
if loop_index == 0:
|
||||
# An override IS the loop's already-per-iteration derived seed → use it
|
||||
# verbatim. The loop_index XOR exists only to vary a CONSTANT widget seed
|
||||
# (the override-OFF path); re-mixing an override that already differs per
|
||||
# iteration is a double-shift that made the actual seed diverge from the
|
||||
# derived series the preview + per-iteration seed-modal locks show.
|
||||
if loop_index == 0 or seed_override is not None:
|
||||
return base & MAX_SAFE_SEED
|
||||
digest = hashlib.sha256(f"loop:{loop_index}".encode()).digest()
|
||||
shift = int.from_bytes(digest[:8], "big")
|
||||
|
||||
+282
-145
@@ -8,6 +8,8 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from engine.syntax.subcat_filter import matches as _subcat_matches
|
||||
from engine.syntax.subcat_filter import parse as _parse_subcat
|
||||
from engine.syntax.tokenize import tokenize_text
|
||||
from engine.syntax.types import (
|
||||
CycleDetectedError,
|
||||
@@ -17,6 +19,7 @@ from engine.syntax.types import (
|
||||
Token,
|
||||
TokenKind,
|
||||
UnknownRefError,
|
||||
deref_var_value,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -114,7 +117,12 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
|
||||
message=f"Unknown variable ${name}",
|
||||
)
|
||||
return ""
|
||||
return str(value)
|
||||
# SP2a list accessor: `$name` joins a ListVar with its separator;
|
||||
# `$name.K` indexes (0-based, out-of-range -> ""). A plain string behaves
|
||||
# as a 1-element list so `$str.0` == `$str` and `$str.1` == "". The
|
||||
# accessor + ListVar-fold contract lives in deref_var_value
|
||||
# (engine/syntax/types.py); the derivation + converter reads share it.
|
||||
return deref_var_value(value, tok.meta.get("index"))
|
||||
|
||||
|
||||
def _push_warning(ctx: ResolveContext, **fields) -> None:
|
||||
@@ -161,8 +169,19 @@ def _resolve_inline_pick(
|
||||
branches: list[str] = tok.meta.get("branches", [])
|
||||
if not branches:
|
||||
return ""
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = branches[chosen_idx]
|
||||
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
|
||||
# honors. Strip the prefix off every branch so it never leaks into output;
|
||||
# only switch to weighted selection when a branch actually carries a weight,
|
||||
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
|
||||
# randrange path — changing the draw would shift every existing seed).
|
||||
values = [_strip_branch_weight(b) for b in branches]
|
||||
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
|
||||
if has_weights:
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
chosen_idx = _weighted_pick_index(weights, ctx.rng)
|
||||
else:
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = values[chosen_idx]
|
||||
if not chosen:
|
||||
return ""
|
||||
nested_tokens = tokenize_text(chosen)
|
||||
@@ -209,53 +228,120 @@ def _weighted_pick_index(weights: list[float], rng) -> int:
|
||||
return len(weights) - 1
|
||||
|
||||
|
||||
def pick_k_unique(items, weights, k, rng):
|
||||
"""Pick ``k`` items from ``items`` without replacement, weighted by the
|
||||
parallel ``weights`` list, returned in pick order. ``k`` is clamped to
|
||||
``len(items)``; ``k <= 0`` -> ``[]``.
|
||||
|
||||
Shared by the inline ``{N$$sep$$}`` block (``_resolve_multi_pick``) and by
|
||||
multi-select wildcards (``wildcard_handler``). Draws from ``rng`` in the
|
||||
same order the inline block always has, so seed behavior is unchanged.
|
||||
"""
|
||||
n = min(max(0, k), len(items))
|
||||
available_idx = list(range(len(items)))
|
||||
available_w = list(weights)
|
||||
chosen = []
|
||||
for _ in range(n):
|
||||
if not available_idx:
|
||||
break
|
||||
j = _weighted_pick_index(available_w, rng)
|
||||
chosen.append(items[available_idx.pop(j)])
|
||||
available_w.pop(j)
|
||||
return chosen
|
||||
|
||||
|
||||
def _resolve_multi_pick(
|
||||
tok: Token,
|
||||
ctx: ResolveContext,
|
||||
depth: int,
|
||||
visited: tuple[str, ...],
|
||||
) -> str:
|
||||
"""Pick `count` branches without replacement, weighted, joined by `sep`.
|
||||
"""Resolve a multi-pick block, joined by `sep`.
|
||||
|
||||
Each chosen branch is recursively resolved.
|
||||
SP2b: `count` is a range `[min, max]` (a fixed `{N$$}` has min==max), and
|
||||
`independent` (the `~` flag) toggles unique-without-replacement (default)
|
||||
vs N draws WITH replacement. A lone nested-ref body in unique mode draws N
|
||||
DISTINCT options from the ref's filtered pool (a per-site SP2a). Each chosen
|
||||
value is recursively resolved.
|
||||
"""
|
||||
# Seedless assembler surface — see _resolve_inline_pick. Render verbatim
|
||||
# rather than freezing a deterministic seed-0 multi-pick.
|
||||
if ctx.surface == "assembler":
|
||||
return tok.raw
|
||||
count: int = tok.meta.get("count", 0)
|
||||
cmin: int = tok.meta.get("min", tok.meta.get("count", 0))
|
||||
cmax: int = tok.meta.get("max", tok.meta.get("count", 0))
|
||||
independent: bool = bool(tok.meta.get("independent", False))
|
||||
sep: str = tok.meta.get("sep", "")
|
||||
branches: list[str] = list(tok.meta.get("branches", []))
|
||||
|
||||
# Range count: a fixed N (min==max) does NOT draw from rng, so a `{N$$}`
|
||||
# reproduces the pre-SP2b seed sequence byte-for-byte. A true range draws.
|
||||
count = cmin if cmin == cmax else ctx.rng.randint(cmin, cmax)
|
||||
if count <= 0:
|
||||
return ""
|
||||
|
||||
# Clamp count to available branches (without-replacement constraint)
|
||||
# Lone nested-ref body, unique mode, count>=2, wildcard surface → pick N
|
||||
# DISTINCT options from the ref's filtered pool (per-site SP2a multi-select).
|
||||
# count==1 stays on the legacy branch path below so its seed is unchanged.
|
||||
if not independent and count >= 2 and len(branches) == 1 and ctx.surface == "wildcard":
|
||||
bt = tokenize_text(branches[0])
|
||||
if len(bt) == 1 and bt[0].kind == TokenKind.REF:
|
||||
ref_tok = bt[0]
|
||||
ref_uuid = ref_tok.meta.get("uuid", "")
|
||||
pool = ref_option_pool(ref_tok, ctx)
|
||||
if not pool:
|
||||
return ""
|
||||
values = [str(o.get("value", "")) for o in pool]
|
||||
weights = [float(o.get("weight", 1) or 1) for o in pool]
|
||||
chosen = pick_k_unique(values, weights, count, ctx.rng)
|
||||
# The multi-picked ref is the carrier of any `@{X}` inside the
|
||||
# values it resolves. We pick on VALUES (not option dicts) so a
|
||||
# specific option id isn't tracked here — set carrier to
|
||||
# (ref_uuid, None) for the recursion. option_id None can't match
|
||||
# a real `pick` entry, so a deeper `@{X}` falls back to
|
||||
# positional first/next/all (design: multi-pick children aren't
|
||||
# individually pickable). Restore the enclosing carrier after.
|
||||
get_carrier = getattr(ctx, "get_carrier", None)
|
||||
set_carrier = getattr(ctx, "set_carrier", None)
|
||||
saved_carrier = (
|
||||
get_carrier() if callable(get_carrier) else None
|
||||
)
|
||||
if callable(set_carrier):
|
||||
set_carrier(ref_uuid, None)
|
||||
try:
|
||||
return sep.join(
|
||||
_resolve_tokens(
|
||||
tokenize_text(v), ctx, depth=depth + 1,
|
||||
visited=visited + (ref_uuid,),
|
||||
) if v else ""
|
||||
for v in chosen
|
||||
)
|
||||
finally:
|
||||
if callable(set_carrier) and saved_carrier is not None:
|
||||
set_carrier(*saved_carrier)
|
||||
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
branch_values = [_strip_branch_weight(b) for b in branches]
|
||||
resolved: list[str] = []
|
||||
|
||||
if independent:
|
||||
# N draws WITH replacement over the branches; each resolved
|
||||
# independently (a lone ref re-rolls every time → variety from nesting).
|
||||
for _ in range(count):
|
||||
j = _weighted_pick_index(weights, ctx.rng)
|
||||
v = branch_values[j]
|
||||
resolved.append(
|
||||
_resolve_tokens(tokenize_text(v), ctx, depth=depth, visited=visited)
|
||||
if v else ""
|
||||
)
|
||||
return sep.join(resolved)
|
||||
|
||||
# Unique over branches (legacy path, now range-aware): N distinct branches.
|
||||
n_picks = min(count, len(branches))
|
||||
if n_picks == 0:
|
||||
return ""
|
||||
|
||||
# Weighted without-replacement: branch weights default to 1; if a branch
|
||||
# uses the `N::value` form (a feature of the inline-pick weight micro-syntax),
|
||||
# the weight is parsed from the prefix. For simplicity, this implementation
|
||||
# treats all branches as weight=1 unless they begin with `<int>::`.
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
branch_values = [_strip_branch_weight(b) for b in branches]
|
||||
|
||||
chosen_indices: list[int] = []
|
||||
available_indices = list(range(len(branches)))
|
||||
available_weights = list(weights)
|
||||
|
||||
for _ in range(n_picks):
|
||||
if not available_indices:
|
||||
break
|
||||
idx = _weighted_pick_index(available_weights, ctx.rng)
|
||||
chosen_indices.append(available_indices.pop(idx))
|
||||
available_weights.pop(idx)
|
||||
|
||||
resolved: list[str] = []
|
||||
for idx in chosen_indices:
|
||||
branch_text = branch_values[idx]
|
||||
chosen_values = pick_k_unique(branch_values, weights, n_picks, ctx.rng)
|
||||
for branch_text in chosen_values:
|
||||
if not branch_text:
|
||||
resolved.append("")
|
||||
continue
|
||||
@@ -265,6 +351,145 @@ def _resolve_multi_pick(
|
||||
return sep.join(resolved)
|
||||
|
||||
|
||||
def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
|
||||
"""Filtered + constrained option pool a ``@{uuid[#name][:expr][!null]}`` ref
|
||||
resolves against — catalog lookup, the ``:expr``/``!null`` sub-category
|
||||
filter, and chain constraints. Shared by ``_resolve_ref`` (single weighted
|
||||
pick) and the SP2b lone-ref multi-pick path.
|
||||
|
||||
Returns ``[]`` (pushing the same ``unknown_ref`` /
|
||||
``ref_subcategory_empty_pool`` warnings, and raising ``UnknownRefError`` in
|
||||
strict mode for an unknown ref) when there is nothing to pick. Does NOT
|
||||
apply the depth / cycle / surface guards — those stay in ``_resolve_ref``
|
||||
and the multi-pick caller.
|
||||
"""
|
||||
uuid = tok.meta.get("uuid", "")
|
||||
module = ctx.get_module(uuid)
|
||||
if module is None:
|
||||
if ctx.strict:
|
||||
raise UnknownRefError(uuid)
|
||||
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="unknown_ref",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "name": cached_name},
|
||||
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
|
||||
)
|
||||
return []
|
||||
|
||||
payload_dict = module.get("payload")
|
||||
if isinstance(payload_dict, dict):
|
||||
options = list(payload_dict.get("options", []))
|
||||
else:
|
||||
options = list(module.get("options", []))
|
||||
|
||||
filter_expr = tok.meta.get("filter_expr")
|
||||
exclude_null = bool(tok.meta.get("exclude_null"))
|
||||
if (isinstance(filter_expr, str) and filter_expr.strip()) or exclude_null:
|
||||
pool_total = len(options)
|
||||
ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None
|
||||
options = [
|
||||
o for o in options
|
||||
if isinstance(o, dict)
|
||||
and (
|
||||
(o.get("is_null") and not exclude_null)
|
||||
or (
|
||||
not o.get("is_null")
|
||||
and _subcat_matches(ast, set(o.get("sub_categories") or []))
|
||||
)
|
||||
)
|
||||
]
|
||||
if not options:
|
||||
# "Matched no options" on its own is true and useless — it cannot
|
||||
# tell a wrong filter from a right filter aimed at a stale pool.
|
||||
# Report WHICH pool was searched, how big it was, and what tags it
|
||||
# actually offers, so the panel answers the question instead of
|
||||
# restating the symptom.
|
||||
available = sorted({
|
||||
t
|
||||
for o in (payload_dict.get("options", []) if isinstance(payload_dict, dict)
|
||||
else module.get("options", []))
|
||||
if isinstance(o, dict)
|
||||
for t in (o.get("sub_categories") or [])
|
||||
if isinstance(t, str)
|
||||
})
|
||||
pool_origin = module.get("pool_origin")
|
||||
library_count = module.get("library_option_count")
|
||||
detail = {
|
||||
"uuid": uuid,
|
||||
"filter_expr": filter_expr,
|
||||
"exclude_null": exclude_null,
|
||||
"pool_total": pool_total,
|
||||
"available_sub_categories": available,
|
||||
}
|
||||
if pool_origin:
|
||||
detail["pool_origin"] = pool_origin
|
||||
if isinstance(library_count, int):
|
||||
detail["library_option_count"] = library_count
|
||||
where = (
|
||||
"this node's copy" if pool_origin == "node"
|
||||
else "the library" if pool_origin == "library"
|
||||
else "the pool"
|
||||
)
|
||||
drift = (
|
||||
f"; the library now has {library_count}"
|
||||
if isinstance(library_count, int) else ""
|
||||
)
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="ref_subcategory_empty_pool",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail=detail,
|
||||
message=(
|
||||
f"@{{{uuid}:{filter_expr or ''}"
|
||||
f"{'!null' if exclude_null else ''}}} matched no options — "
|
||||
f"searched {where} ({pool_total} option"
|
||||
f"{'' if pool_total == 1 else 's'}){drift}"
|
||||
),
|
||||
)
|
||||
return []
|
||||
|
||||
get_constraints = getattr(ctx, "get_constraints", None)
|
||||
get_picks = getattr(ctx, "get_picks", None)
|
||||
if callable(get_constraints) and callable(get_picks):
|
||||
constraints = get_constraints()
|
||||
if constraints:
|
||||
from engine.modules._constraints import (
|
||||
apply_constraints_for_target,
|
||||
warn_excludes_all,
|
||||
)
|
||||
# SP3: share the ctx-resident hit counter so a nested-ref
|
||||
# target occurrence counts toward the same first/next
|
||||
# coverage as direct top-level instances. `carrier_ctx` is
|
||||
# the identity of the carrier wildcard whose chosen option
|
||||
# value textually contains THIS `@{uuid}` ref — set on the
|
||||
# resolve frame by the enclosing wildcard handler (top-level
|
||||
# carrier) or by `_resolve_ref` (deeper carrier). A `pick`
|
||||
# selector's `nested` occurrence matches on it; first / next
|
||||
# / all ignore it. None when no carrier is set (a ref at the
|
||||
# surface root) — then `pick` simply won't cover.
|
||||
get_hits = getattr(ctx, "get_constraint_hits", None)
|
||||
hits = get_hits() if callable(get_hits) else {}
|
||||
get_carrier_ctx = getattr(ctx, "get_carrier_ctx", None)
|
||||
carrier_ctx = get_carrier_ctx() if callable(get_carrier_ctx) else None
|
||||
options, any_applied = apply_constraints_for_target(
|
||||
options, uuid, constraints, get_picks(), ctx.warnings,
|
||||
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
|
||||
)
|
||||
if any_applied:
|
||||
warn_excludes_all(options, uuid, ctx.warnings)
|
||||
return options
|
||||
|
||||
|
||||
def _resolve_ref(
|
||||
tok: Token,
|
||||
ctx: ResolveContext,
|
||||
@@ -312,8 +537,10 @@ def _resolve_ref(
|
||||
)
|
||||
return ""
|
||||
|
||||
# Surface check
|
||||
if ctx.surface != "wildcard":
|
||||
# Surface check — wildcard options AND derivation action values may host
|
||||
# `@{}` refs (both run resolve_text inside a seeded Context with the chain
|
||||
# constraint bucket threaded). combine + assembler stay gated.
|
||||
if ctx.surface not in ("wildcard", "derivation"):
|
||||
if ctx.strict:
|
||||
raise RefOutOfSurfaceError(uuid, ctx.surface)
|
||||
# Try to resolve module name for the warning detail (best-effort)
|
||||
@@ -332,121 +559,13 @@ def _resolve_ref(
|
||||
)
|
||||
return ""
|
||||
|
||||
# Catalog lookup
|
||||
module = ctx.get_module(uuid)
|
||||
if module is None:
|
||||
if ctx.strict:
|
||||
raise UnknownRefError(uuid)
|
||||
# Surface the cached `#name` from the ref token if the original
|
||||
# syntax carried one (`@{uuid#name}`). Lets WP_Debug + RichTextPreview
|
||||
# render a friendly label even when the target wildcard has been
|
||||
# deleted from the library — same fallback chain RichTextPreview
|
||||
# uses for unresolved chips elsewhere.
|
||||
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="unknown_ref",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "name": cached_name},
|
||||
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
|
||||
)
|
||||
# Catalog lookup + `:expr`/`!null` filter + chain constraints, factored
|
||||
# into ref_option_pool (shared with the SP2b lone-ref multi-pick path).
|
||||
# The strict-unknown raise + the unknown / empty-pool warnings live there.
|
||||
options = ref_option_pool(tok, ctx)
|
||||
if not options:
|
||||
return ""
|
||||
|
||||
# Pick weighted option.
|
||||
# Support both SnapshotEntry shape (payload.options, spec §2.4) and the
|
||||
# legacy flat shape used by existing unit tests (options at top level).
|
||||
payload_dict = module.get("payload")
|
||||
if isinstance(payload_dict, dict):
|
||||
options = list(payload_dict.get("options", []))
|
||||
else:
|
||||
options = list(module.get("options", []))
|
||||
|
||||
# Optional per-call sub-category filter: `@{uuid:warm,cool}` keeps
|
||||
# only options whose `sub_category` is in the requested list. Same
|
||||
# semantics as chain-level `instance.category_filter` but scoped to
|
||||
# this specific ref, so a shared library wildcard (e.g. `@{color}`)
|
||||
# can be narrowed differently at every call site without authoring
|
||||
# separate library entries. Empty post-filter pool → empty string
|
||||
# + warning so the user sees the unsatisfiable filter rather than
|
||||
# silently falling through to the unfiltered list.
|
||||
#
|
||||
# Null semantics (inverted 2026-05-25): the wildcard's `is_null`
|
||||
# option is INCLUDED by default — alongside whatever sub-cats the
|
||||
# filter lists. The reserved keyword `"null"` in the filter list
|
||||
# EXCLUDES the null option from the pool. So:
|
||||
# `@{uuid}` → all options (incl. null)
|
||||
# `@{uuid:warm}` → warm options + null
|
||||
# `@{uuid:warm,null}` → warm options, null excluded
|
||||
# `@{uuid:null}` → all non-null options (filter-only-null
|
||||
# = "exclude null, no sub-cat filter")
|
||||
# Sub-category names called literally `"null"` are forbidden by
|
||||
# WildcardHandler.validate_payload so the keyword can never clash
|
||||
# with a real sub-cat.
|
||||
sub_filter = tok.meta.get("sub_categories")
|
||||
if isinstance(sub_filter, list) and sub_filter:
|
||||
allowed_subs = set(sub_filter)
|
||||
exclude_null = "null" in allowed_subs
|
||||
allowed_subs.discard("null")
|
||||
options = [
|
||||
o for o in options
|
||||
if isinstance(o, dict)
|
||||
and (
|
||||
(o.get("is_null") and not exclude_null)
|
||||
or (
|
||||
not o.get("is_null")
|
||||
and (not allowed_subs or o.get("sub_category") in allowed_subs)
|
||||
)
|
||||
)
|
||||
]
|
||||
if not options:
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="ref_subcategory_empty_pool",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "sub_categories": list(sub_filter)},
|
||||
message=(
|
||||
f"@{{{uuid}:{','.join(sub_filter)}}} matched no options"
|
||||
),
|
||||
)
|
||||
return ""
|
||||
|
||||
# Apply chain-level constraints whose `target_wildcard_id` matches
|
||||
# this nested ref's uuid. Pre-2026-05 only top-level wildcards saw
|
||||
# constraints — nested `@{B}` from inside another wildcard's
|
||||
# option value silently bypassed every rule, defeating composed
|
||||
# pipelines. ResolveContext exposes `get_constraints` / `get_picks`
|
||||
# for this path; both default to empty for hand-built resolve
|
||||
# contexts in tests so the legacy fast path keeps working.
|
||||
get_constraints = getattr(ctx, "get_constraints", None)
|
||||
get_picks = getattr(ctx, "get_picks", None)
|
||||
if callable(get_constraints) and callable(get_picks):
|
||||
constraints = get_constraints()
|
||||
if constraints:
|
||||
from engine.modules._constraints import (
|
||||
apply_constraints_for_target,
|
||||
warn_excludes_all,
|
||||
)
|
||||
# First-instance one-shot semantic: thread the consumed
|
||||
# set so this nested-ref resolve path participates in the
|
||||
# same one-shot bookkeeping as top-level wildcard rolls.
|
||||
# See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
|
||||
get_consumed = getattr(ctx, "get_consumed_constraints", None)
|
||||
consumed = get_consumed() if callable(get_consumed) else set()
|
||||
options, any_applied = apply_constraints_for_target(
|
||||
options, uuid, constraints, get_picks(), ctx.warnings,
|
||||
consumed=consumed,
|
||||
)
|
||||
if any_applied:
|
||||
warn_excludes_all(options, uuid, ctx.warnings)
|
||||
|
||||
chosen = _pick_weighted(options, ctx.rng)
|
||||
if chosen is None:
|
||||
return ""
|
||||
@@ -455,6 +574,24 @@ def _resolve_ref(
|
||||
if not chosen_value:
|
||||
return ""
|
||||
|
||||
# Recursive resolve with depth+1, visited extended
|
||||
# Recursive resolve with depth+1, visited extended. This ref'd
|
||||
# wildcard is the carrier of any `@{X}` inside its OWN chosen option
|
||||
# value, so stamp (its uuid, the chosen option's id) on the frame for
|
||||
# the duration of the recursion, then restore the prior carrier. The
|
||||
# carrier_uid here is the library uuid (a ref'd wildcard has no row
|
||||
# `_uid`); a `pick` keyed on a top-level row uid won't match it —
|
||||
# that's the expected deeper-than-one-hop behaviour (positional
|
||||
# first/next/all cover deeper nesting). Mirrors the rng save/restore.
|
||||
nested_tokens = tokenize_text(chosen_value)
|
||||
get_carrier = getattr(ctx, "get_carrier", None)
|
||||
set_carrier = getattr(ctx, "set_carrier", None)
|
||||
if callable(get_carrier) and callable(set_carrier):
|
||||
saved_carrier = get_carrier()
|
||||
set_carrier(uuid, chosen.get("id"))
|
||||
try:
|
||||
return _resolve_tokens(
|
||||
nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,)
|
||||
)
|
||||
finally:
|
||||
set_carrier(*saved_carrier)
|
||||
return _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Boolean sub-category filter — parser, matcher, normalizer, validators.
|
||||
|
||||
Grammar (precedence not > and > or; or/and are n-ary, flattened):
|
||||
expr := or
|
||||
or := and ( ("or" | ",") and )*
|
||||
and := unary ( "and" unary )*
|
||||
unary := "not" unary | atom
|
||||
atom := SUBCAT | "(" or ")"
|
||||
|
||||
Mirror: src/manager/parsing/subcatFilter.ts — keep behavior identical
|
||||
(shared fixture in tests/fixtures/subcat_filter_cases.json).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
RESERVED = {"and", "or", "not", "null"}
|
||||
# A sub-category NAME is one safe token (see validate_subcat_name).
|
||||
_NAME_RE = re.compile(r"^[^\s()!,#:}@{$]+$")
|
||||
_TOKEN_RE = re.compile(r"\s*(\(|\)|,|[^\s()!,#:}@{$]+)")
|
||||
|
||||
|
||||
class ParseError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _tokenize(s: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
m = _TOKEN_RE.match(s, i)
|
||||
if not m:
|
||||
if s[i:].strip() == "": # only trailing whitespace left
|
||||
break
|
||||
raise ParseError(f"unexpected character {s[i]!r}")
|
||||
out.append(m.group(1))
|
||||
i = m.end()
|
||||
return out
|
||||
|
||||
|
||||
def parse(s: str) -> dict[str, Any] | None:
|
||||
"""Parse an expression string to an AST, or None when empty."""
|
||||
toks = _tokenize(s)
|
||||
if not toks:
|
||||
return None
|
||||
pos = 0
|
||||
|
||||
def peek() -> str | None:
|
||||
return toks[pos] if pos < len(toks) else None
|
||||
|
||||
def eat() -> str:
|
||||
nonlocal pos
|
||||
t = toks[pos]
|
||||
pos += 1
|
||||
return t
|
||||
|
||||
def p_or() -> dict[str, Any]:
|
||||
kids = [p_and()]
|
||||
while peek() in ("or", ","):
|
||||
eat()
|
||||
kids.append(p_and())
|
||||
return kids[0] if len(kids) == 1 else {"op": "or", "kids": kids}
|
||||
|
||||
def p_and() -> dict[str, Any]:
|
||||
kids = [p_unary()]
|
||||
while peek() == "and":
|
||||
eat()
|
||||
kids.append(p_unary())
|
||||
return kids[0] if len(kids) == 1 else {"op": "and", "kids": kids}
|
||||
|
||||
def p_unary() -> dict[str, Any]:
|
||||
if peek() == "not":
|
||||
eat()
|
||||
return {"op": "not", "x": p_unary()}
|
||||
return p_atom()
|
||||
|
||||
def p_atom() -> dict[str, Any]:
|
||||
t = peek()
|
||||
if t == "(":
|
||||
eat()
|
||||
inner = p_or()
|
||||
if peek() != ")":
|
||||
raise ParseError("missing closing paren )")
|
||||
eat()
|
||||
return inner
|
||||
if t is None:
|
||||
raise ParseError("expression incomplete (missing a term)")
|
||||
if t in ("and", "or", "not", ")", ","):
|
||||
raise ParseError(f"unexpected token {t!r} (missing an operator/term?)")
|
||||
eat()
|
||||
return {"tag": t}
|
||||
|
||||
ast = p_or()
|
||||
if pos < len(toks):
|
||||
raise ParseError(f"unexpected token {toks[pos]!r} (missing an operator?)")
|
||||
return ast
|
||||
|
||||
|
||||
def matches(ast: dict[str, Any] | None, tags: set[str]) -> bool:
|
||||
"""True iff the option's `tags` satisfy the expression. Empty expr => True."""
|
||||
if ast is None:
|
||||
return True
|
||||
if "tag" in ast:
|
||||
return ast["tag"] in tags
|
||||
op = ast["op"]
|
||||
if op == "not":
|
||||
return not matches(ast["x"], tags)
|
||||
if op == "and":
|
||||
return all(matches(k, tags) for k in ast["kids"])
|
||||
return any(matches(k, tags) for k in ast["kids"]) # or
|
||||
|
||||
|
||||
def reads_as(ast: dict[str, Any] | None) -> str:
|
||||
"""Canonical pretty-print: flatten same-op chains; parenthesize only a
|
||||
child whose operator differs from the parent's."""
|
||||
if ast is None:
|
||||
return ""
|
||||
if "tag" in ast:
|
||||
return ast["tag"]
|
||||
op = ast["op"]
|
||||
if op == "not":
|
||||
c = ast["x"]
|
||||
inner = reads_as(c)
|
||||
return f"not ({inner})" if ("op" in c and c["op"] in ("and", "or")) else f"not {inner}"
|
||||
parts = []
|
||||
for k in ast["kids"]:
|
||||
inner = reads_as(k)
|
||||
if "op" in k and k["op"] in ("and", "or") and k["op"] != op:
|
||||
parts.append(f"({inner})")
|
||||
else:
|
||||
parts.append(inner)
|
||||
return f" {op} ".join(parts)
|
||||
|
||||
|
||||
def validate_subcat_name(name: str) -> str | None:
|
||||
"""Return an error string, or None when the name is a valid single token."""
|
||||
if not name:
|
||||
return "name is empty"
|
||||
if name.lower() in RESERVED:
|
||||
return f"reserved word: {name!r} (and/or/not/null are not allowed)"
|
||||
if any(ch.isspace() for ch in name):
|
||||
return "name must not contain whitespace"
|
||||
if not _NAME_RE.match(name):
|
||||
bad = next(ch for ch in name if ch in "()!,#:}@{$")
|
||||
return f"disallowed character: {bad!r}"
|
||||
return None
|
||||
|
||||
|
||||
def validate_expression(s: str, known: set[str]) -> str | None:
|
||||
"""Return an error string, or None when the expression is valid against `known`."""
|
||||
try:
|
||||
ast = parse(s)
|
||||
except ParseError as e:
|
||||
return str(e)
|
||||
if ast is None:
|
||||
return None
|
||||
seen: set[str] = set()
|
||||
|
||||
def walk(n: dict[str, Any]) -> None:
|
||||
if "tag" in n:
|
||||
seen.add(n["tag"])
|
||||
return
|
||||
if n["op"] == "not":
|
||||
walk(n["x"])
|
||||
return
|
||||
for k in n["kids"]:
|
||||
walk(k)
|
||||
|
||||
walk(ast)
|
||||
for t in seen:
|
||||
if t.lower() in RESERVED:
|
||||
return f"reserved word used as a term: {t!r}"
|
||||
if t not in known:
|
||||
return f"Unknown sub-category: {t!r}"
|
||||
return None
|
||||
+61
-39
@@ -13,26 +13,34 @@ import re
|
||||
|
||||
from engine.syntax.types import Token, TokenKind
|
||||
|
||||
# `$name` — identifier starts with letter or underscore, then word chars
|
||||
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
|
||||
# `@{8hex}`, `@{8hex#name}`, `@{8hex:subcat[,subcat...]}`, or
|
||||
# `@{8hex#name:subcat[,subcat...]}` — exactly 8 lowercase hex chars,
|
||||
# optionally followed by a `#name` segment (display label cached at
|
||||
# write-time so a broken ref still tells the user what the wildcard
|
||||
# was called), optionally followed by a `:` + comma-separated
|
||||
# sub-category filter. Filter restricts the nested wildcard's pick to
|
||||
# options whose `sub_category` is in the supplied list — same
|
||||
# semantics as instance.category_filter at the chain level, but
|
||||
# scoped per-call so one library wildcard can be narrowed differently
|
||||
# from each call site. The name segment is purely informational —
|
||||
# resolver matches on uuid only.
|
||||
# `$name` or `$name.K` — identifier starts with letter or underscore, then
|
||||
# word chars, with an optional `.K` list-index accessor (SP2a). Group 2 = the
|
||||
# index digits when present.
|
||||
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?")
|
||||
# 4-segment ref grammar (fixed order): `@{8hex [#name] [:expr] [!null]}`.
|
||||
# group 1 — uuid: exactly 8 lowercase hex chars.
|
||||
# group 2 — optional `#name`: display label cached at write-time so a
|
||||
# broken ref still tells the user what the wildcard was
|
||||
# called. Purely informational — the resolver matches uuid.
|
||||
# group 3 — optional `:expr`: a BOOLEAN sub-category filter expression
|
||||
# (`and`/`or`/`not`/parens/comma=or). Parsed + matched by the
|
||||
# shared `engine.syntax.subcat_filter` matcher; narrows the
|
||||
# nested pick to options whose tag set satisfies the
|
||||
# expression — same matcher as instance.category_filter.
|
||||
# group 4 — optional `!null`: the exclude-null marker (drops the null
|
||||
# option from the nested pick). Separate from the expression.
|
||||
#
|
||||
# Name char-class `[^#:}@{]` mirrors the wildcard-name validator so
|
||||
# names that would re-trigger this regex (or break it) are rejected at
|
||||
# the editor + API boundary, not at parse time.
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
|
||||
# Multi-pick prefix: {N$$sep$$ where N is one or more digits and sep can be empty.
|
||||
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+)\$\$(.*?)\$\$", flags=re.DOTALL)
|
||||
# Name/expr char-classes exclude `!` (the null-marker delimiter) so the
|
||||
# segments stay unambiguous; sub-category names additionally forbid the
|
||||
# boolean grammar chars (see `subcat_filter.validate_subcat_name`),
|
||||
# enforced at the editor + API boundary, not at parse time.
|
||||
_REF_RE = re.compile(
|
||||
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
|
||||
)
|
||||
# Multi-pick prefix: {N$$sep$$ or {N-M~$$sep$$ — group 1 is the count (a fixed
|
||||
# `N` or a `N-M` range), group 2 is the optional `~` independent flag (repeats
|
||||
# allowed; absent = unique), group 3 is the separator (may be empty). SP2b.
|
||||
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+(?:-\d+)?)(~?)\$\$(.*?)\$\$", flags=re.DOTALL)
|
||||
|
||||
|
||||
def _split_top_level_pipes(s: str) -> list[str]:
|
||||
@@ -54,7 +62,7 @@ def _split_top_level_pipes(s: str) -> list[str]:
|
||||
|
||||
def _scan_brace_block(
|
||||
text: str, start: int
|
||||
) -> tuple[int, list[str], int | None, str | None] | None:
|
||||
) -> tuple[int, list[str], tuple[int, int, bool] | None, str | None] | None:
|
||||
"""Try to scan a `{...}` block starting at `text[start] == '{'`.
|
||||
|
||||
Returns:
|
||||
@@ -97,13 +105,21 @@ def _scan_brace_block(
|
||||
body_with_braces = "{" + body # match against `{N$$sep$$...`
|
||||
multi_match = _MULTI_PREFIX_RE.match(body_with_braces)
|
||||
if multi_match:
|
||||
count = int(multi_match.group(1))
|
||||
sep = multi_match.group(2)
|
||||
count_raw = multi_match.group(1) # "N" or "N-M"
|
||||
independent = multi_match.group(2) == "~" # SP2b: repeats allowed
|
||||
sep = multi_match.group(3)
|
||||
# Body remaining after the prefix is the branch list
|
||||
prefix_len = multi_match.end() - 1 # subtract the leading `{` we added
|
||||
rest = body[prefix_len:]
|
||||
branches = _split_top_level_pipes(rest)
|
||||
return (end_index, branches, count, sep)
|
||||
if "-" in count_raw:
|
||||
lo, hi = count_raw.split("-", 1)
|
||||
cmin, cmax = int(lo), int(hi)
|
||||
else:
|
||||
cmin = cmax = int(count_raw)
|
||||
if cmin > cmax: # normalize a swapped range
|
||||
cmin, cmax = cmax, cmin
|
||||
return (end_index, branches, (cmin, cmax, independent), sep)
|
||||
|
||||
# Single-pick: must contain at least one top-level `|`
|
||||
branches = _split_top_level_pipes(body)
|
||||
@@ -169,12 +185,15 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
m = _VAR_RE.match(text, i)
|
||||
if m:
|
||||
_flush_text(i)
|
||||
var_meta: dict[str, object] = {"name": m.group(1)}
|
||||
if m.group(2) is not None:
|
||||
var_meta["index"] = int(m.group(2))
|
||||
out.append(Token(
|
||||
kind=TokenKind.VAR,
|
||||
raw=m.group(0),
|
||||
start=i,
|
||||
end=m.end(),
|
||||
meta={"name": m.group(1)},
|
||||
meta=var_meta,
|
||||
))
|
||||
i = m.end()
|
||||
continue
|
||||
@@ -185,24 +204,21 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
m = _REF_RE.match(text, i)
|
||||
if m:
|
||||
_flush_text(i)
|
||||
# Group 1: uuid. Group 2: optional display name. Group
|
||||
# 3: optional sub-category filter (split on comma, strip
|
||||
# whitespace, drop empties). Empty / whitespace-only
|
||||
# filter is equivalent to "no filter" — keeps
|
||||
# `@{xyz:}` from accidentally banning every option.
|
||||
filter_raw = m.group(3)
|
||||
sub_categories: list[str] = []
|
||||
if filter_raw is not None:
|
||||
sub_categories = [
|
||||
sc for sc in (s.strip() for s in filter_raw.split(","))
|
||||
if sc
|
||||
]
|
||||
# Group 1: uuid. Group 2: optional cached display name.
|
||||
# Group 3: optional boolean sub-category filter
|
||||
# expression (stored raw; the resolver parses it via the
|
||||
# shared subcat_filter matcher). A whitespace-only
|
||||
# `:expr` is "no filter" — keeps `@{xyz:}` from banning
|
||||
# every option. Group 4: the `!null` exclude-null marker.
|
||||
meta: dict = {"uuid": m.group(1)}
|
||||
name_raw = m.group(2)
|
||||
if name_raw:
|
||||
meta["name"] = name_raw
|
||||
if sub_categories:
|
||||
meta["sub_categories"] = sub_categories
|
||||
expr_raw = m.group(3)
|
||||
if expr_raw is not None and expr_raw.strip():
|
||||
meta["filter_expr"] = expr_raw
|
||||
if m.group(4) == "null":
|
||||
meta["exclude_null"] = True
|
||||
out.append(Token(
|
||||
kind=TokenKind.REF,
|
||||
raw=m.group(0),
|
||||
@@ -228,12 +244,18 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
meta={"branches": branches},
|
||||
))
|
||||
else:
|
||||
cmin, cmax, independent = count
|
||||
out.append(Token(
|
||||
kind=TokenKind.DP_MULTI,
|
||||
raw=text[i:end_index],
|
||||
start=i,
|
||||
end=end_index,
|
||||
meta={"count": count, "sep": sep, "branches": branches},
|
||||
# `count` kept == max for back-compat readers; min/max
|
||||
# carry the range, independent carries the `~` flag (SP2b).
|
||||
meta={
|
||||
"min": cmin, "max": cmax, "independent": independent,
|
||||
"count": cmax, "sep": sep, "branches": branches,
|
||||
},
|
||||
))
|
||||
i = end_index
|
||||
continue
|
||||
|
||||
+65
-2
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -53,6 +54,66 @@ class Token:
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListVar:
|
||||
"""A list-valued variable plus the separator used to join it for a bare
|
||||
``$name`` reference (SP2a multi-select). Produced only by a multi-select
|
||||
wildcard; exists only inside one resolution pass — never persisted or
|
||||
published. ``$name`` renders ``sep.join(items)``; ``$name.K`` indexes
|
||||
(0-based; out-of-range -> "").
|
||||
"""
|
||||
|
||||
items: list[str]
|
||||
sep: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""A bare ``$name`` reference renders the list joined by ``sep``.
|
||||
Defining ``__str__`` (the @dataclass ``__repr__`` stays, for debug)
|
||||
keeps a list value that leaks into a plain ``str()`` context rendering
|
||||
as the user-facing joined string instead of ``ListVar(items=[...])``."""
|
||||
return self.sep.join(self.items)
|
||||
|
||||
|
||||
_VAR_ACCESSOR_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\.(\d+)$")
|
||||
|
||||
|
||||
def split_var_accessor(name: str) -> tuple[str, int | None]:
|
||||
"""Split a bare var reference into ``(base_name, index)`` (SP2a `.K`).
|
||||
|
||||
``"mood.0"`` -> ``("mood", 0)``; ``"mood"`` -> ``("mood", None)``. A name
|
||||
that is not an ``ident.digits`` shape is returned whole with
|
||||
``index=None`` so unusual keys pass through unchanged. Callers strip any
|
||||
leading ``$`` before calling.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return ("", None)
|
||||
m = _VAR_ACCESSOR_RE.match(name)
|
||||
if m:
|
||||
return (m.group(1), int(m.group(2)))
|
||||
return (name, None)
|
||||
|
||||
|
||||
def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
|
||||
"""Render a resolved var value to a string, honoring an optional ``.K``
|
||||
list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` ->
|
||||
``items[K]`` or ``""`` when out of range. A plain string behaves as a
|
||||
1-element list (``.0`` == itself, ``.K`` > 0 == ``""``). ``None`` -> ``""``.
|
||||
|
||||
Single source of the accessor contract: resolve.py, derivation_handler.py
|
||||
and converters.py all defer here so the read sites never drift.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, ListVar):
|
||||
if index is not None:
|
||||
return value.items[index] if 0 <= index < len(value.items) else ""
|
||||
return value.sep.join(value.items)
|
||||
s = value if isinstance(value, str) else str(value)
|
||||
if index is not None:
|
||||
return s if index == 0 else ""
|
||||
return s
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResolveContext(Protocol):
|
||||
"""Interface the resolver depends on. Construct via build_resolve_ctx in
|
||||
@@ -67,8 +128,10 @@ class ResolveContext(Protocol):
|
||||
developer_mode: bool
|
||||
warnings: list[dict[str, Any]]
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
"""Return the bound value of `name`, or None if unbound."""
|
||||
def get_var(self, name: str) -> str | ListVar | None:
|
||||
"""Return the bound value of `name`, or None if unbound. A
|
||||
multi-select wildcard binds a ``ListVar`` (SP2a); everything else
|
||||
binds a ``str``."""
|
||||
...
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
|
||||
+10
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.0.1",
|
||||
"version": "2.13.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
@@ -13,6 +13,7 @@
|
||||
"build:manager": "vite build --mode manager",
|
||||
"build": "pnpm build:extension && pnpm build:manager",
|
||||
"docs:copy": "node scripts/copy-help-docs.mjs",
|
||||
"docs:figures": "node scripts/build-flow-figures.mjs",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,vue}\"",
|
||||
"size": "node scripts/check-bundle-size.mjs",
|
||||
@@ -24,18 +25,17 @@
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"test:py": "pytest",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"announce": "node scripts/discord-announce.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^7.0.0",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5",
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^7.0.0"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.14",
|
||||
"postcss": "^8.4.47",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@comfyorg/comfyui-frontend-types": "^1.42.15",
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
@@ -48,12 +48,15 @@
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vue/test-utils": "^2.4.8",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.31.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^29.0.2",
|
||||
"postcss": "^8.4.47",
|
||||
"semantic-release": "^25.0.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
|
||||
Generated
+3
@@ -20,6 +20,9 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^4.4.5
|
||||
version: 4.6.4(vue@3.5.33(typescript@5.9.3))
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@comfyorg/comfyui-frontend-types':
|
||||
specifier: ^1.42.15
|
||||
|
||||
+131
-127
@@ -1,130 +1,134 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 280" role="img" aria-label="WP Context emits resolved variables, Prompt Assembler fills them, CLIP Encode produces conditioning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 202" width="880" height="202" role="img" aria-label="Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits resolved dollar-variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning">
|
||||
<style><![CDATA[
|
||||
.bg { fill: #14161e; }
|
||||
.panel { fill: #1a1d2a; }
|
||||
.border-cyan { stroke: #3a8fb7; }
|
||||
.border-violet { stroke: #7c6ad6; }
|
||||
.border-neutral { stroke: #6a6a6a; }
|
||||
.border-dim { stroke: #353841; }
|
||||
.ico-cyan { fill: #7ec8e3; }
|
||||
.ico-violet { fill: #c4b5fd; }
|
||||
.ico-neutral { fill: #aab; }
|
||||
.name { fill: #d6d8e0; font: 600 13px 'Inter', system-ui, sans-serif; }
|
||||
.sub { fill: #6f7384; font: 400 10px ui-monospace, monospace; }
|
||||
.arrow-lbl { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
|
||||
.arrow-head { fill: #4a4d55; }
|
||||
.modules-header { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.pill-bg { fill: #1a1d2a; }
|
||||
.pill-stroke-wc { stroke: #b29cf2; }
|
||||
.pill-stroke-fv { stroke: #2dd4bf; }
|
||||
.pill-stroke-cb { stroke: #6ad28b; }
|
||||
.pill-stroke-dv { stroke: #f4a261; }
|
||||
.pill-stroke-cs { stroke: #ec96b8; }
|
||||
.pill-stroke-var { stroke: #c4b5fd; }
|
||||
.pill-text-wc { fill: #b29cf2; }
|
||||
.pill-text-fv { fill: #2dd4bf; }
|
||||
.pill-text-cb { fill: #6ad28b; }
|
||||
.pill-text-dv { fill: #f4a261; }
|
||||
.pill-text-cs { fill: #ec96b8; }
|
||||
.pill-text-var { fill: #c4b5fd; }
|
||||
.pill-text { font: 500 10px 'Inter', system-ui, sans-serif; }
|
||||
.pill { fill-opacity: 0.12; stroke-width: 1; }
|
||||
.resolved { fill: #6f7384; font: 600 9.5px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.down-arrow { stroke: #4a4d55; stroke-width: 1.5; fill: none; }
|
||||
.frame, .card, .pill, .chip { stroke-width: 1; }
|
||||
.cap { font: 400 10px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .06em; }
|
||||
.feeder { font: 400 10.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||
.nm { font: 600 12px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||
.id { font: 400 9.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
|
||||
.lbl, .varcap { font: 400 9.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .05em; }
|
||||
.chipt { font: 400 10.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
|
||||
|
||||
.frame { fill: #1e1e1e; stroke: #3a3a3a; }
|
||||
.card { fill: #232323; stroke: #4a4a4a; }
|
||||
.card--wp { stroke: #2dd4bf; stroke-opacity: .55; }
|
||||
.ico--wp { color: #2dd4bf; }
|
||||
.ico--ext { color: #aaaaaa; }
|
||||
.ico--wildcard { color: #a78bfa; }
|
||||
.ico--fixed { color: #22d3ee; }
|
||||
.ico--combine { color: #34d399; }
|
||||
.ico--derivation { color: #fbbf24; }
|
||||
.ico--constraint { color: #f472b6; }
|
||||
.pill { fill: #232323; stroke: #3a3a3a; }
|
||||
.feeder { fill: #aaaaaa; }
|
||||
.nm { fill: #dddddd; }
|
||||
.id, .lbl, .cap, .varcap { fill: #666666; }
|
||||
.ln { stroke: #4a4a4a; }
|
||||
.wash { stop-color: #2dd4bf; }
|
||||
.flow0 { stop-color: #4a4a4a; }
|
||||
.head { fill: #4a4a4a; }
|
||||
.chip { fill: #232323; }
|
||||
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
|
||||
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
|
||||
.chip--c { stroke: #34d399; stroke-opacity: .4; }
|
||||
.chipt--a { fill: #c4b5fd; }
|
||||
.chipt--b { fill: #67e8f9; }
|
||||
.chipt--c { fill: #86efac; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
.frame { fill: #f5f5f5; stroke: #b0b0b0; }
|
||||
.card { fill: #ffffff; stroke: #999999; }
|
||||
.card--wp { stroke: #0f766e; stroke-opacity: .55; }
|
||||
.ico--wp { color: #0f766e; }
|
||||
.ico--ext { color: #555555; }
|
||||
.ico--wildcard { color: #6d28d9; }
|
||||
.ico--fixed { color: #1d4ed8; }
|
||||
.ico--combine { color: #065f46; }
|
||||
.ico--derivation { color: #92400e; }
|
||||
.ico--constraint { color: #9d174d; }
|
||||
.pill { fill: #ffffff; stroke: #b0b0b0; }
|
||||
.feeder { fill: #555555; }
|
||||
.nm { fill: #1a1a1a; }
|
||||
.id, .lbl, .cap, .varcap { fill: #888888; }
|
||||
.ln { stroke: #999999; }
|
||||
.wash { stop-color: #0f766e; }
|
||||
.flow0 { stop-color: #999999; }
|
||||
.head { fill: #999999; }
|
||||
.chip { fill: #ffffff; }
|
||||
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
|
||||
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
|
||||
.chip--c { stroke: #34d399; stroke-opacity: .4; }
|
||||
.chipt--a { fill: #6d28d9; }
|
||||
.chipt--b { fill: #1d4ed8; }
|
||||
.chipt--c { fill: #065f46; }
|
||||
}
|
||||
]]></style>
|
||||
|
||||
<rect class="bg" width="980" height="280" rx="8" />
|
||||
|
||||
<!-- MODULES INSIDE THE CONTEXT header -->
|
||||
<text class="modules-header" x="490" y="28" text-anchor="middle">Modules inside the context</text>
|
||||
|
||||
<!-- Module kind pills row -->
|
||||
<g transform="translate(245, 42)">
|
||||
<!-- Wildcard -->
|
||||
<rect class="pill pill-stroke-wc" x="0" y="0" width="76" height="22" rx="11" fill="#b29cf2" />
|
||||
<text class="pill-text pill-text-wc" x="38" y="15" text-anchor="middle">✦ Wildcard</text>
|
||||
<!-- Fixed Values -->
|
||||
<rect class="pill pill-stroke-fv" x="84" y="0" width="92" height="22" rx="11" fill="#2dd4bf" />
|
||||
<text class="pill-text pill-text-fv" x="130" y="15" text-anchor="middle">⌘ Fixed Values</text>
|
||||
<!-- Combine -->
|
||||
<rect class="pill pill-stroke-cb" x="184" y="0" width="76" height="22" rx="11" fill="#6ad28b" />
|
||||
<text class="pill-text pill-text-cb" x="222" y="15" text-anchor="middle">⇄ Combine</text>
|
||||
<!-- Derivation -->
|
||||
<rect class="pill pill-stroke-dv" x="268" y="0" width="84" height="22" rx="11" fill="#f4a261" />
|
||||
<text class="pill-text pill-text-dv" x="310" y="15" text-anchor="middle">⇆ Derivation</text>
|
||||
<!-- Constraint -->
|
||||
<rect class="pill pill-stroke-cs" x="360" y="0" width="84" height="22" rx="11" fill="#ec96b8" />
|
||||
<text class="pill-text pill-text-cs" x="402" y="15" text-anchor="middle">⊥ Constraint</text>
|
||||
</g>
|
||||
|
||||
<!-- Down arrow indicating these go INTO the Context -->
|
||||
<path class="down-arrow" d="M 490 78 L 490 96 M 484 90 L 490 96 L 496 90" />
|
||||
|
||||
<!-- ===== Main flow row at y≈170 ===== -->
|
||||
|
||||
<!-- WP Context box -->
|
||||
<g transform="translate(40, 130)">
|
||||
<rect class="panel border-cyan" x="0" y="0" width="160" height="100" rx="10" stroke-width="2" />
|
||||
<text class="ico-cyan" x="80" y="32" text-anchor="middle" font-size="20">⌬</text>
|
||||
<text class="name" x="80" y="58" text-anchor="middle">WP Context</text>
|
||||
<text class="sub" x="80" y="78" text-anchor="middle">rolls + resolves</text>
|
||||
</g>
|
||||
|
||||
<!-- EMITS arrow -->
|
||||
<g transform="translate(210, 180)">
|
||||
<text class="arrow-lbl" x="50" y="-12" text-anchor="middle">Emits</text>
|
||||
<line class="arrow-line" x1="0" y1="0" x2="92" y2="0" />
|
||||
<polygon class="arrow-head" points="92,-5 100,0 92,5" />
|
||||
</g>
|
||||
|
||||
<!-- Resolved vars cluster (no box; chips on transparent) -->
|
||||
<g transform="translate(320, 160)">
|
||||
<!-- $subject -->
|
||||
<rect class="pill pill-stroke-var" x="0" y="0" width="68" height="22" rx="11" fill="#c4b5fd" />
|
||||
<text class="pill-text pill-text-var" x="34" y="15" text-anchor="middle">$subject</text>
|
||||
<!-- $style -->
|
||||
<rect class="pill pill-stroke-var" x="76" y="0" width="52" height="22" rx="11" fill="#c4b5fd" />
|
||||
<text class="pill-text pill-text-var" x="102" y="15" text-anchor="middle">$style</text>
|
||||
<!-- $lighting (second row, centered) -->
|
||||
<rect class="pill pill-stroke-var" x="32" y="30" width="72" height="22" rx="11" fill="#c4b5fd" />
|
||||
<text class="pill-text pill-text-var" x="68" y="45" text-anchor="middle">$lighting</text>
|
||||
<!-- RESOLVED $VARS caption -->
|
||||
<text class="resolved" x="64" y="72" text-anchor="middle">Resolved $vars</text>
|
||||
</g>
|
||||
|
||||
<!-- FILLS arrow -->
|
||||
<g transform="translate(465, 180)">
|
||||
<text class="arrow-lbl" x="50" y="-12" text-anchor="middle">Fills</text>
|
||||
<line class="arrow-line" x1="0" y1="0" x2="92" y2="0" />
|
||||
<polygon class="arrow-head" points="92,-5 100,0 92,5" />
|
||||
</g>
|
||||
|
||||
<!-- Prompt Assembler box -->
|
||||
<g transform="translate(575, 130)">
|
||||
<rect class="panel border-violet" x="0" y="0" width="184" height="100" rx="10" stroke-width="2" />
|
||||
<text class="ico-violet" x="92" y="32" text-anchor="middle" font-size="20">≡</text>
|
||||
<text class="name" x="92" y="58" text-anchor="middle">Prompt Assembler</text>
|
||||
<text class="sub" x="92" y="78" text-anchor="middle">"a $style portrait…"</text>
|
||||
</g>
|
||||
|
||||
<!-- PROMPT arrow -->
|
||||
<g transform="translate(770, 180)">
|
||||
<text class="arrow-lbl" x="40" y="-12" text-anchor="middle">Prompt</text>
|
||||
<line class="arrow-line" x1="0" y1="0" x2="72" y2="0" />
|
||||
<polygon class="arrow-head" points="72,-5 80,0 72,5" />
|
||||
</g>
|
||||
|
||||
<!-- CLIP Encode box -->
|
||||
<g transform="translate(860, 130)">
|
||||
<rect class="panel border-neutral" x="0" y="0" width="88" height="100" rx="10" stroke-width="2" />
|
||||
<text class="ico-neutral" x="44" y="32" text-anchor="middle" font-size="20">⚡</text>
|
||||
<text class="name" x="44" y="58" text-anchor="middle">CLIP</text>
|
||||
<text class="sub" x="44" y="78" text-anchor="middle">conditioning</text>
|
||||
</g>
|
||||
|
||||
<!-- Caption -->
|
||||
<text class="resolved" x="490" y="260" text-anchor="middle" style="text-transform:none; letter-spacing:0;">
|
||||
Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP
|
||||
</text>
|
||||
<defs>
|
||||
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
|
||||
width by 100 percent of the HEIGHT, anchored top-left — so it spans
|
||||
the whole panel. Squashing it by the aspect ratio instead pins it to
|
||||
the corner and leaves the rest looking flat. -->
|
||||
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
|
||||
<stop class="wash" offset="0" stop-opacity="0.10"/>
|
||||
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
|
||||
<stop class="wash" offset="1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.25" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.2" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect class="frame" x="0.5" y="0.5" width="879" height="201" rx="12"/>
|
||||
<rect x="0.5" y="0.5" width="879" height="201" rx="12" fill="url(#wash)"/>
|
||||
<text class="cap" x="440" y="30" text-anchor="middle">MODULES INSIDE THE CONTEXT</text>
|
||||
<rect class="pill" x="214.69" y="40" width="77.68" height="21" rx="10.5"/>
|
||||
<g class="ico--wildcard"><g fill="currentColor" transform="translate(224.69 45.5) scale(0.4166666666666667)"><path fill-rule="evenodd" clip-rule="evenodd" d="M18.8923 5.29743L20.1462 5.71539C20.4197 5.80656 20.4197 6.19343 20.1462 6.2846L18.8923 6.70256C18.8027 6.73242 18.7324 6.80272 18.7026 6.8923L18.2846 8.14618C18.1934 8.41969 17.8066 8.41969 17.7154 8.14618L17.2974 6.8923C17.2676 6.80272 17.1973 6.73242 17.1077 6.70256L15.8538 6.2846C15.5803 6.19343 15.5803 5.80656 15.8538 5.71539L17.1077 5.29743C17.1973 5.26757 17.2676 5.19728 17.2974 5.1077L17.7154 3.85381C17.8066 3.5803 18.1934 3.5803 18.2846 3.85381L18.7026 5.1077C18.7324 5.19728 18.8027 5.26757 18.8923 5.29743ZM9.19361 5.04743C9.45192 4.27248 10.5481 4.2725 10.8064 5.04743L12.3429 9.65707L16.9526 11.1936C17.7275 11.4519 17.7275 12.5481 16.9526 12.8064L12.3429 14.3429L10.8064 18.9526C10.5481 19.7275 9.45192 19.7275 9.19361 18.9526L7.65706 14.3429L3.04742 12.8064C2.27247 12.5481 2.27249 11.4519 3.04742 11.1936L7.65706 9.65707L9.19361 5.04743ZM9.99999 7.37171L8.97731 10.4397C8.89271 10.6935 8.69354 10.8927 8.43973 10.9773L5.3717 12L8.43973 13.0227C8.69354 13.1073 8.89271 13.3064 8.97731 13.5603L9.99999 16.6283L11.0227 13.5603C11.1073 13.3064 11.3064 13.1073 11.5603 13.0227L14.6283 12L11.5603 10.9773C11.3064 10.8927 11.1073 10.6935 11.0227 10.4397L9.99999 7.37171ZM20.1462 17.7154L18.8923 17.2974C18.8027 17.2676 18.7324 17.1973 18.7026 17.1077L18.2846 15.8538C18.1934 15.5803 17.8066 15.5803 17.7154 15.8538L17.2974 17.1077C17.2676 17.1973 17.1973 17.2676 17.1077 17.2974L15.8538 17.7154C15.5803 17.8066 15.5803 18.1934 15.8538 18.2846L17.1077 18.7026C17.1973 18.7324 17.2676 18.8027 17.2974 18.8923L17.7154 20.1462C17.8066 20.4197 18.1934 20.4197 18.2846 20.1462L18.7026 18.8923C18.7324 18.8027 18.8027 18.7324 18.8923 18.7026L20.1462 18.2846C20.4197 18.1934 20.4197 17.8066 20.1462 17.7154Z"/></g></g>
|
||||
<text class="feeder" x="238.69" y="54">Wildcard</text>
|
||||
<rect class="pill" x="298.37" y="40" width="99.52" height="21" rx="10.5"/>
|
||||
<g class="ico--fixed"><g fill="currentColor" transform="translate(308.37 45.5) scale(0.4166666666666667)"><path d="M12.32,19.98c-.58,0-1.16-.22-1.6-.66l-6.48-6.47c-.14-.14-.22-.33-.22-.53V4.77c0-.41,.34-.75,.75-.75h7.54c.2,0,.39,.08,.53,.22l6.48,6.48c.87,.88,.87,2.31,0,3.19,0,0,0,0,0,0l-5.41,5.41c-.44,.44-1.02,.66-1.6,.66Zm-6.8-7.97l6.26,6.25c.3,.29,.78,.29,1.07,0l5.41-5.41c.29-.29,.29-.77,0-1.07l-6.26-6.26H5.52v6.48Zm13.27,1.37h0Zm-10.29-3.63c-.69,0-1.25-.56-1.25-1.25s.56-1.25,1.25-1.25,1.25,.56,1.25,1.25-.56,1.25-1.25,1.25Z"/></g></g>
|
||||
<text class="feeder" x="322.37" y="54">Fixed Values</text>
|
||||
<rect class="pill" x="403.89" y="40" width="72.22" height="21" rx="10.5"/>
|
||||
<g class="ico--combine"><g fill="currentColor" transform="translate(413.89 45.5) scale(0.4166666666666667)"><g id="link"><path d="M7.23,18.25A4,4,0,0,1,4.4,17.09a4.23,4.23,0,0,1,.15-5.95L8.31,7.35A4.44,4.44,0,0,1,11.42,6,4,4,0,0,1,14.25,7.2a4.25,4.25,0,0,1-.15,6l-1.26,1.26a.75.75,0,1,1-1.06-1.06L13,12.1a2.73,2.73,0,0,0,.14-3.84,2.77,2.77,0,0,0-3.8.15L5.61,12.19A2.74,2.74,0,0,0,5.46,16a2.5,2.5,0,0,0,2,.71.74.74,0,0,1,.81.67.75.75,0,0,1-.67.82Z"/><path d="M12.58,18A4,4,0,0,1,9.75,16.8a4.25,4.25,0,0,1,.15-6l1.26-1.26a.75.75,0,1,1,1.06,1.06L11,11.9a2.73,2.73,0,0,0-.14,3.84,2.77,2.77,0,0,0,3.8-.15l3.77-3.78A2.74,2.74,0,0,0,18.54,8a2.5,2.5,0,0,0-2-.71.74.74,0,0,1-.81-.67.75.75,0,0,1,.67-.82A4,4,0,0,1,19.6,6.91a4.23,4.23,0,0,1-.15,5.95l-3.76,3.79A4.44,4.44,0,0,1,12.58,18Z"/></g></g></g>
|
||||
<text class="feeder" x="427.89" y="54">Combine</text>
|
||||
<rect class="pill" x="482.11" y="40" width="88.6" height="21" rx="10.5"/>
|
||||
<g class="ico--derivation"><g fill="currentColor" transform="translate(492.11 45.5) scale(0.4166666666666667)"><path d="M19.75,16c0,.41-.34,.75-.75,.75H6.81l1.22,1.22c.29,.29,.29,.77,0,1.06-.15,.15-.34,.22-.53,.22s-.38-.07-.53-.22l-2.5-2.5c-.07-.07-.12-.15-.16-.24-.08-.18-.08-.39,0-.57,.04-.09,.09-.17,.16-.24l2.5-2.5c.29-.29,.77-.29,1.06,0s.29,.77,0,1.06l-1.22,1.22h12.19c.41,0,.75,.34,.75,.75ZM5,8.75h12.19l-1.22,1.22c-.29,.29-.29,.77,0,1.06,.15,.15,.34,.22,.53,.22s.38-.07,.53-.22l2.5-2.5c.07-.07,.12-.15,.16-.24,.08-.18,.08-.39,0-.57-.04-.09-.09-.17-.16-.24l-2.5-2.5c-.29-.29-.77-.29-1.06,0s-.29,.77,0,1.06l1.22,1.22H5c-.41,0-.75,.34-.75,.75s.34,.75,.75,.75Z"/></g></g>
|
||||
<text class="feeder" x="506.11" y="54">Derivation</text>
|
||||
<rect class="pill" x="576.71" y="40" width="88.6" height="21" rx="10.5"/>
|
||||
<g class="ico--constraint"><g fill="currentColor" transform="translate(586.71 45.5) scale(0.4166666666666667)"><g id="filter"><path d="M14,20.5H10a.76.76,0,0,1-.75-.75V12L3.9,4.69a.73.73,0,0,1-.07-.78A.76.76,0,0,1,4.5,3.5h15a.76.76,0,0,1,.67.41.73.73,0,0,1-.07.78L14.75,12v7.75A.76.76,0,0,1,14,20.5ZM10.75,19h2.5V11.75a.71.71,0,0,1,.15-.44L18,5H6l4.62,6.31a.71.71,0,0,1,.15.44Z"/></g></g></g>
|
||||
<text class="feeder" x="600.71" y="54">Constraint</text>
|
||||
<g class="ico--ext"><g fill="currentColor" transform="translate(434 72) scale(0.5)"><g id="angle-down"><path d="M12,14.5a.74.74,0,0,1-.53-.22L8,10.78A.75.75,0,0,1,9,9.72l3,3,3-3A.75.75,0,0,1,16,10.78l-3.5,3.5A.74.74,0,0,1,12,14.5Z"/></g></g></g>
|
||||
<rect class="card card--wp" x="50" y="104" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 115) scale(0.6666666666666666)"><g id="sitemap"><path d="M13.5,9.75h-3A1.76,1.76,0,0,1,8.75,8V5A1.76,1.76,0,0,1,10.5,3.25h3A1.76,1.76,0,0,1,15.25,5V8A1.76,1.76,0,0,1,13.5,9.75Zm-3-5a.25.25,0,0,0-.25.25V8a.25.25,0,0,0,.25.25h3A.25.25,0,0,0,13.75,8V5a.25.25,0,0,0-.25-.25Z"/><path d="M6,19.75H4A1.76,1.76,0,0,1,2.25,18V16A1.76,1.76,0,0,1,4,14.25H6A1.76,1.76,0,0,1,7.75,16v2A1.76,1.76,0,0,1,6,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25H6A.25.25,0,0,0,6.25,18V16A.25.25,0,0,0,6,15.75Z"/><path d="M13,19.75H11A1.76,1.76,0,0,1,9.25,18V16A1.76,1.76,0,0,1,11,14.25h2A1.76,1.76,0,0,1,14.75,16v2A1.76,1.76,0,0,1,13,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M20,19.75H18A1.76,1.76,0,0,1,16.25,18V16A1.76,1.76,0,0,1,18,14.25h2A1.76,1.76,0,0,1,21.75,16v2A1.76,1.76,0,0,1,20,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M19,15.75a.76.76,0,0,1-.75-.75V13a.25.25,0,0,0-.25-.25H6a.25.25,0,0,0-.25.25v2a.75.75,0,0,1-1.5,0V13A1.76,1.76,0,0,1,6,11.25H18A1.76,1.76,0,0,1,19.75,13v2A.76.76,0,0,1,19,15.75Z"/><path d="M12,15.75a.76.76,0,0,1-.75-.75V9a.75.75,0,0,1,1.5,0v6A.76.76,0,0,1,12,15.75Z"/></g></g></g>
|
||||
<text class="nm" x="112" y="147" text-anchor="middle">WP Context</text>
|
||||
<text class="id" x="112" y="161" text-anchor="middle">rolls + resolves</text>
|
||||
<text class="lbl" x="226" y="128" text-anchor="middle">EMITS</text>
|
||||
<rect fill="url(#flow)" x="182" y="136.2" width="82" height="1.6"/>
|
||||
<path class="head" d="M264 132.5 L270 137 L264 141.5 Z"/>
|
||||
<rect class="chip chip--a" x="287.4" y="102" width="64.4" height="19" rx="9.5"/>
|
||||
<text class="chipt chipt--a" x="319.59999999999997" y="115" text-anchor="middle">$subject</text>
|
||||
<rect class="chip chip--b" x="356.79999999999995" y="102" width="51.8" height="19" rx="9.5"/>
|
||||
<text class="chipt chipt--b" x="382.69999999999993" y="115" text-anchor="middle">$style</text>
|
||||
<rect class="chip chip--c" x="312.65" y="126" width="70.69999999999999" height="19" rx="9.5"/>
|
||||
<text class="chipt chipt--c" x="348" y="139" text-anchor="middle">$lighting</text>
|
||||
<text class="varcap" x="348" y="164" text-anchor="middle">RESOLVED $VARS</text>
|
||||
<text class="lbl" x="457" y="128" text-anchor="middle">FILLS</text>
|
||||
<rect fill="url(#flow)" x="426" y="136.2" width="56" height="1.6"/>
|
||||
<path class="head" d="M482 132.5 L488 137 L482 141.5 Z"/>
|
||||
<rect class="card card--wp" x="496" y="104" width="140" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(558 115) scale(0.6666666666666666)"><g id="align-left"><path d="M13.93,11h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,7h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,15h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M13.93,19h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/></g></g></g>
|
||||
<text class="nm" x="566" y="147" text-anchor="middle">Prompt Assembler</text>
|
||||
<text class="id" x="566" y="161" text-anchor="middle">"A $style portrait…"</text>
|
||||
<text class="lbl" x="673" y="128" text-anchor="middle">PROMPT</text>
|
||||
<rect fill="url(#flow)" x="644" y="136.2" width="52" height="1.6"/>
|
||||
<path class="head" d="M696 132.5 L702 137 L696 141.5 Z"/>
|
||||
<rect class="card card--ext" x="710" y="104" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--ext"><g fill="currentColor" transform="translate(764 115) scale(0.6666666666666666)"><g id="bolt"><path d="M11.09,21.5a.67.67,0,0,1-.24,0,.83.83,0,0,1-.59-.81v-.11l.9-6.35H6.82a.8.8,0,0,1-.71-.43.85.85,0,0,1,0-.86l2-3.49,4.1-6.52a.79.79,0,0,1,.92-.35.83.83,0,0,1,.59.81v.11l-.9,6.35h4.35a.8.8,0,0,1,.71.43.85.85,0,0,1,0,.86l-2,3.49-4.1,6.52A.79.79,0,0,1,11.09,21.5Zm-.53-1.21h0Zm-2.63-7.6h4a.84.84,0,0,1,.83.85v.11l-.59,4.14,2.5-4,1.44-2.48h-4a.84.84,0,0,1-.83-.85v-.11l.59-4.14-2.5,4Zm-.57,1Zm9.28-3.34ZM13.44,3.7h0Z"/></g></g></g>
|
||||
<text class="nm" x="772" y="147" text-anchor="middle">CLIP Encode</text>
|
||||
<text class="id" x="772" y="161" text-anchor="middle">conditioning</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,102 +1,132 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 240" role="img" aria-label="WP Context Loop emits N prompts and feeds WP Seed List N seeds; both fan out into KSampler in lockstep">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 286" width="880" height="286" role="img" aria-label="WP Context Loop drives WP Context and the Prompt Assembler to produce N prompts, CLIP Encode turns them into conditioning for KSampler, and WP Seed List supplies the matching seed for each iteration directly to KSampler">
|
||||
<style><![CDATA[
|
||||
.bg { fill: #14161e; }
|
||||
.panel { fill: #1a1d2a; }
|
||||
.border-loop { stroke: #7c6ad6; }
|
||||
.border-ctx { stroke: #3a8fb7; }
|
||||
.border-asm { stroke: #7c6ad6; }
|
||||
.border-seed { stroke: #3a8fb7; }
|
||||
.border-k { stroke: #6a6a6a; }
|
||||
.ico-loop { fill: #c4b5fd; }
|
||||
.ico-ctx { fill: #7ec8e3; }
|
||||
.ico-asm { fill: #c4b5fd; }
|
||||
.ico-seed { fill: #7ec8e3; }
|
||||
.ico-k { fill: #aab; }
|
||||
.name { fill: #d6d8e0; font: 600 13px 'Inter', system-ui, sans-serif; }
|
||||
.sub { fill: #6f7384; font: 400 10px ui-monospace, monospace; }
|
||||
.arrow-lbl { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
|
||||
.arrow-line-dim { stroke: #353841; stroke-width: 2; fill: none; stroke-dasharray: 5 4; }
|
||||
.arrow-head { fill: #4a4d55; }
|
||||
.caption { fill: #6f7384; font: 500 11px 'Inter', system-ui, sans-serif; }
|
||||
.frame, .card, .pill, .chip { stroke-width: 1; }
|
||||
.cap { font: 400 10px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .06em; }
|
||||
.feeder { font: 400 10.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||
.nm { font: 600 12px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||
.id { font: 400 9.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
|
||||
.lbl, .varcap { font: 400 9.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .05em; }
|
||||
.chipt { font: 400 10.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
|
||||
|
||||
.frame { fill: #1e1e1e; stroke: #3a3a3a; }
|
||||
.card { fill: #232323; stroke: #4a4a4a; }
|
||||
.card--wp { stroke: #2dd4bf; stroke-opacity: .55; }
|
||||
.ico--wp { color: #2dd4bf; }
|
||||
.ico--ext { color: #aaaaaa; }
|
||||
.ico--wildcard { color: #a78bfa; }
|
||||
.ico--fixed { color: #22d3ee; }
|
||||
.ico--combine { color: #34d399; }
|
||||
.ico--derivation { color: #fbbf24; }
|
||||
.ico--constraint { color: #f472b6; }
|
||||
.pill { fill: #232323; stroke: #3a3a3a; }
|
||||
.feeder { fill: #aaaaaa; }
|
||||
.nm { fill: #dddddd; }
|
||||
.id, .lbl, .cap, .varcap { fill: #666666; }
|
||||
.ln { stroke: #4a4a4a; }
|
||||
.wash { stop-color: #2dd4bf; }
|
||||
.flow0 { stop-color: #4a4a4a; }
|
||||
.head { fill: #4a4a4a; }
|
||||
.chip { fill: #232323; }
|
||||
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
|
||||
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
|
||||
.chip--c { stroke: #34d399; stroke-opacity: .4; }
|
||||
.chipt--a { fill: #c4b5fd; }
|
||||
.chipt--b { fill: #67e8f9; }
|
||||
.chipt--c { fill: #86efac; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
.frame { fill: #f5f5f5; stroke: #b0b0b0; }
|
||||
.card { fill: #ffffff; stroke: #999999; }
|
||||
.card--wp { stroke: #0f766e; stroke-opacity: .55; }
|
||||
.ico--wp { color: #0f766e; }
|
||||
.ico--ext { color: #555555; }
|
||||
.ico--wildcard { color: #6d28d9; }
|
||||
.ico--fixed { color: #1d4ed8; }
|
||||
.ico--combine { color: #065f46; }
|
||||
.ico--derivation { color: #92400e; }
|
||||
.ico--constraint { color: #9d174d; }
|
||||
.pill { fill: #ffffff; stroke: #b0b0b0; }
|
||||
.feeder { fill: #555555; }
|
||||
.nm { fill: #1a1a1a; }
|
||||
.id, .lbl, .cap, .varcap { fill: #888888; }
|
||||
.ln { stroke: #999999; }
|
||||
.wash { stop-color: #0f766e; }
|
||||
.flow0 { stop-color: #999999; }
|
||||
.head { fill: #999999; }
|
||||
.chip { fill: #ffffff; }
|
||||
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
|
||||
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
|
||||
.chip--c { stroke: #34d399; stroke-opacity: .4; }
|
||||
.chipt--a { fill: #6d28d9; }
|
||||
.chipt--b { fill: #1d4ed8; }
|
||||
.chipt--c { fill: #065f46; }
|
||||
}
|
||||
]]></style>
|
||||
|
||||
<rect class="bg" width="980" height="240" rx="8" />
|
||||
|
||||
<!-- WP Context Loop -->
|
||||
<g transform="translate(30, 70)">
|
||||
<rect class="panel border-loop" x="0" y="0" width="160" height="100" rx="10" stroke-width="2" />
|
||||
<text class="ico-loop" x="80" y="32" text-anchor="middle" font-size="20">↺</text>
|
||||
<text class="name" x="80" y="58" text-anchor="middle">WP Context Loop</text>
|
||||
<text class="sub" x="80" y="78" text-anchor="middle">N prompts</text>
|
||||
</g>
|
||||
|
||||
<!-- Loop splits into TWO lanes -->
|
||||
|
||||
<!-- Lane A: loop_config → Seed List → seed[] -->
|
||||
<g transform="translate(200, 60)">
|
||||
<text class="arrow-lbl" x="50" y="-6" text-anchor="middle">loop_config</text>
|
||||
<line class="arrow-line" x1="0" y1="6" x2="92" y2="6" />
|
||||
<polygon class="arrow-head" points="92,1 100,6 92,11" />
|
||||
</g>
|
||||
<g transform="translate(310, 30)">
|
||||
<rect class="panel border-seed" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
|
||||
<text class="ico-seed" x="80" y="28" text-anchor="middle" font-size="18">#</text>
|
||||
<text class="name" x="80" y="50" text-anchor="middle">WP Seed List</text>
|
||||
<text class="sub" x="80" y="68" text-anchor="middle">N seeds</text>
|
||||
</g>
|
||||
|
||||
<!-- Lane B: context → WP Context -->
|
||||
<g transform="translate(200, 160)">
|
||||
<text class="arrow-lbl" x="50" y="-6" text-anchor="middle">context</text>
|
||||
<line class="arrow-line" x1="0" y1="6" x2="92" y2="6" />
|
||||
<polygon class="arrow-head" points="92,1 100,6 92,11" />
|
||||
</g>
|
||||
<g transform="translate(310, 130)">
|
||||
<rect class="panel border-ctx" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
|
||||
<text class="ico-ctx" x="80" y="28" text-anchor="middle" font-size="18">⌬</text>
|
||||
<text class="name" x="80" y="50" text-anchor="middle">WP Context</text>
|
||||
<text class="sub" x="80" y="68" text-anchor="middle">module stack</text>
|
||||
</g>
|
||||
|
||||
<!-- WP Context → Prompt Assembler -->
|
||||
<g transform="translate(480, 160)">
|
||||
<line class="arrow-line" x1="0" y1="6" x2="62" y2="6" />
|
||||
<polygon class="arrow-head" points="62,1 70,6 62,11" />
|
||||
</g>
|
||||
|
||||
<!-- Prompt Assembler -->
|
||||
<g transform="translate(560, 130)">
|
||||
<rect class="panel border-asm" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
|
||||
<text class="ico-asm" x="80" y="28" text-anchor="middle" font-size="18">≡</text>
|
||||
<text class="name" x="80" y="50" text-anchor="middle">Prompt Assembler</text>
|
||||
<text class="sub" x="80" y="68" text-anchor="middle">N prompts</text>
|
||||
</g>
|
||||
|
||||
<!-- Seed List → KSampler (top lane) -->
|
||||
<g transform="translate(480, 60)">
|
||||
<text class="arrow-lbl" x="180" y="-6" text-anchor="middle">seed[]</text>
|
||||
<path class="arrow-line" d="M 0 6 L 360 6 L 360 64" />
|
||||
<polygon class="arrow-head" points="355,59 360,67 365,59" />
|
||||
</g>
|
||||
|
||||
<!-- Assembler → KSampler (bottom lane joining top arrow at sampler) -->
|
||||
<g transform="translate(730, 160)">
|
||||
<text class="arrow-lbl" x="40" y="-6" text-anchor="middle">prompt[]</text>
|
||||
<line class="arrow-line" x1="0" y1="6" x2="62" y2="6" />
|
||||
<polygon class="arrow-head" points="62,1 70,6 62,11" />
|
||||
</g>
|
||||
|
||||
<!-- KSampler -->
|
||||
<g transform="translate(810, 130)">
|
||||
<rect class="panel border-k" x="0" y="0" width="140" height="80" rx="10" stroke-width="2" />
|
||||
<text class="ico-k" x="70" y="28" text-anchor="middle" font-size="18">⚡</text>
|
||||
<text class="name" x="70" y="50" text-anchor="middle">KSampler</text>
|
||||
<text class="sub" x="70" y="68" text-anchor="middle">N unique images</text>
|
||||
</g>
|
||||
|
||||
<!-- Caption -->
|
||||
<text class="caption" x="490" y="225" text-anchor="middle">
|
||||
Loop emits N prompts · Seed List emits N seeds · both lists fan out into the sampler in lockstep — iteration N's prompt pairs with seed N
|
||||
</text>
|
||||
<defs>
|
||||
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
|
||||
width by 100 percent of the HEIGHT, anchored top-left — so it spans
|
||||
the whole panel. Squashing it by the aspect ratio instead pins it to
|
||||
the corner and leaves the rest looking flat. -->
|
||||
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
|
||||
<stop class="wash" offset="0" stop-opacity="0.10"/>
|
||||
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
|
||||
<stop class="wash" offset="1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.25" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.2" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect class="frame" x="0.5" y="0.5" width="879" height="285" rx="12"/>
|
||||
<rect x="0.5" y="0.5" width="879" height="285" rx="12" fill="url(#wash)"/>
|
||||
<text class="cap" x="440" y="30" text-anchor="middle">BOTH LISTS FAN OUT IN LOCKSTEP — ITERATION N MEETS SEED N</text>
|
||||
<rect class="card card--wp" x="48.199999999999996" y="54" width="127.60000000000001" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 65) scale(0.6666666666666666)"><g id="refresh"><path d="M12,20.75a7.25,7.25,0,0,1,0-14.5h2.5a.75.75,0,0,1,0,1.5H12a5.75,5.75,0,1,0,5.75,5.75.75.75,0,0,1,1.5,0A7.26,7.26,0,0,1,12,20.75Z"/><path d="M12,10.75a.74.74,0,0,1-.53-.22.75.75,0,0,1,0-1.06L13.94,7,11.47,4.53a.75.75,0,1,1,1.06-1.06l3,3a.75.75,0,0,1,0,1.06l-3,3A.74.74,0,0,1,12,10.75Z"/></g></g></g>
|
||||
<text class="nm" x="112" y="97" text-anchor="middle">WP Context Loop</text>
|
||||
<text class="id" x="112" y="111" text-anchor="middle">N iterations</text>
|
||||
<rect class="card card--wp" x="50" y="158" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 169) scale(0.6666666666666666)"><g id="list"><path d="M19,12.75H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M19,8.25H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M19,17.25H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M5,8.5a1,1,0,0,1-.38-.07,1.46,1.46,0,0,1-.33-.22A1,1,0,0,1,4,7.5a1.05,1.05,0,0,1,.29-.71.93.93,0,0,1,.33-.21,1,1,0,0,1,.76,0,1,1,0,0,1,.33.21A1.05,1.05,0,0,1,6,7.5a1,1,0,0,1-.29.71,1.46,1.46,0,0,1-.33.22A1,1,0,0,1,5,8.5Z"/><path d="M5,13a1,1,0,0,1-.38-.08,1.15,1.15,0,0,1-.33-.21A1,1,0,0,1,4,12a1.05,1.05,0,0,1,.29-.71,1.15,1.15,0,0,1,.33-.21A1,1,0,0,1,5.2,11l.18.06.18.09a1.58,1.58,0,0,1,.15.12A1.05,1.05,0,0,1,6,12a1,1,0,0,1-1,1Z"/><path d="M5,17.5a1,1,0,0,1-.38-.07,1.46,1.46,0,0,1-.33-.22,1.15,1.15,0,0,1-.21-.33.94.94,0,0,1,0-.76,1.15,1.15,0,0,1,.21-.33,1,1,0,0,1,1.09-.21,1,1,0,0,1,.33.21,1.15,1.15,0,0,1,.21.33.94.94,0,0,1,0,.76,1.15,1.15,0,0,1-.21.33A1,1,0,0,1,5,17.5Z"/></g></g></g>
|
||||
<text class="nm" x="112" y="201" text-anchor="middle">WP Seed List</text>
|
||||
<text class="id" x="112" y="215" text-anchor="middle">N seeds</text>
|
||||
<rect class="card card--wp" x="278" y="54" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(332 65) scale(0.6666666666666666)"><g id="sitemap"><path d="M13.5,9.75h-3A1.76,1.76,0,0,1,8.75,8V5A1.76,1.76,0,0,1,10.5,3.25h3A1.76,1.76,0,0,1,15.25,5V8A1.76,1.76,0,0,1,13.5,9.75Zm-3-5a.25.25,0,0,0-.25.25V8a.25.25,0,0,0,.25.25h3A.25.25,0,0,0,13.75,8V5a.25.25,0,0,0-.25-.25Z"/><path d="M6,19.75H4A1.76,1.76,0,0,1,2.25,18V16A1.76,1.76,0,0,1,4,14.25H6A1.76,1.76,0,0,1,7.75,16v2A1.76,1.76,0,0,1,6,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25H6A.25.25,0,0,0,6.25,18V16A.25.25,0,0,0,6,15.75Z"/><path d="M13,19.75H11A1.76,1.76,0,0,1,9.25,18V16A1.76,1.76,0,0,1,11,14.25h2A1.76,1.76,0,0,1,14.75,16v2A1.76,1.76,0,0,1,13,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M20,19.75H18A1.76,1.76,0,0,1,16.25,18V16A1.76,1.76,0,0,1,18,14.25h2A1.76,1.76,0,0,1,21.75,16v2A1.76,1.76,0,0,1,20,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M19,15.75a.76.76,0,0,1-.75-.75V13a.25.25,0,0,0-.25-.25H6a.25.25,0,0,0-.25.25v2a.75.75,0,0,1-1.5,0V13A1.76,1.76,0,0,1,6,11.25H18A1.76,1.76,0,0,1,19.75,13v2A.76.76,0,0,1,19,15.75Z"/><path d="M12,15.75a.76.76,0,0,1-.75-.75V9a.75.75,0,0,1,1.5,0v6A.76.76,0,0,1,12,15.75Z"/></g></g></g>
|
||||
<text class="nm" x="340" y="97" text-anchor="middle">WP Context</text>
|
||||
<text class="id" x="340" y="111" text-anchor="middle">module stack</text>
|
||||
<rect class="card card--wp" x="499.08" y="54" width="133.84" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(558 65) scale(0.6666666666666666)"><g id="align-left"><path d="M13.93,11h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,7h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,15h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M13.93,19h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/></g></g></g>
|
||||
<text class="nm" x="566" y="97" text-anchor="middle">Prompt Assembler</text>
|
||||
<text class="id" x="566" y="111" text-anchor="middle">N prompts</text>
|
||||
<rect class="card card--ext" x="504" y="158" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--ext"><g fill="currentColor" transform="translate(558 169) scale(0.6666666666666666)"><g id="bolt"><path d="M11.09,21.5a.67.67,0,0,1-.24,0,.83.83,0,0,1-.59-.81v-.11l.9-6.35H6.82a.8.8,0,0,1-.71-.43.85.85,0,0,1,0-.86l2-3.49,4.1-6.52a.79.79,0,0,1,.92-.35.83.83,0,0,1,.59.81v.11l-.9,6.35h4.35a.8.8,0,0,1,.71.43.85.85,0,0,1,0,.86l-2,3.49-4.1,6.52A.79.79,0,0,1,11.09,21.5Zm-.53-1.21h0Zm-2.63-7.6h4a.84.84,0,0,1,.83.85v.11l-.59,4.14,2.5-4,1.44-2.48h-4a.84.84,0,0,1-.83-.85v-.11l.59-4.14-2.5,4Zm-.57,1Zm9.28-3.34ZM13.44,3.7h0Z"/></g></g></g>
|
||||
<text class="nm" x="566" y="201" text-anchor="middle">CLIP Encode</text>
|
||||
<text class="id" x="566" y="215" text-anchor="middle">N conditionings</text>
|
||||
<rect class="card card--ext" x="710" y="158" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--ext"><g fill="currentColor" transform="translate(764 169) scale(0.6666666666666666)"><g id="image"><path d="M18,4.25H6A2.75,2.75,0,0,0,3.25,7V17A2.75,2.75,0,0,0,6,19.75H18A2.75,2.75,0,0,0,20.75,17V7A2.75,2.75,0,0,0,18,4.25ZM6,5.75H18A1.25,1.25,0,0,1,19.25,7v8.19l-2.72-2.72a.7.7,0,0,0-.56-.22.79.79,0,0,0-.55.27l-1.29,1.55-4.6-4.6A.7.7,0,0,0,9,9.25a.79.79,0,0,0-.55.27L4.75,13.93V7A1.25,1.25,0,0,1,6,5.75ZM4.75,17v-.73l4.3-5.16,4.12,4.12-2.52,3H6A1.25,1.25,0,0,1,4.75,17ZM18,18.25H12.6l3.45-4.14,3.15,3.15A1.23,1.23,0,0,1,18,18.25Z"/></g></g></g>
|
||||
<text class="nm" x="772" y="201" text-anchor="middle">KSampler</text>
|
||||
<text class="id" x="772" y="215" text-anchor="middle">N images</text>
|
||||
<text class="lbl" x="226.9" y="78" text-anchor="middle">CONTEXT</text>
|
||||
<rect fill="url(#flow)" x="183.8" y="86.2" width="80.19999999999999" height="1.6"/>
|
||||
<path class="head" d="M264 82.5 L270 87 L264 91.5 Z"/>
|
||||
<text class="lbl" x="122" y="143">LOOP_CONFIG</text>
|
||||
<rect fill="url(#flowv)" x="111.2" y="120" width="1.6" height="32"/>
|
||||
<path class="head" d="M108 152 L112 158 L116 152 Z"/>
|
||||
<text class="lbl" x="450.53999999999996" y="78" text-anchor="middle">$VARS</text>
|
||||
<rect fill="url(#flow)" x="410" y="86.2" width="75.07999999999998" height="1.6"/>
|
||||
<path class="head" d="M485.08 82.5 L491.08 87 L485.08 91.5 Z"/>
|
||||
<text class="lbl" x="576" y="141">PROMPT</text>
|
||||
<rect fill="url(#flowv)" x="565.2" y="120" width="1.6" height="32"/>
|
||||
<path class="head" d="M562 152 L566 158 L570 152 Z"/>
|
||||
<text class="lbl" x="669" y="182" text-anchor="middle">COND</text>
|
||||
<rect fill="url(#flow)" x="636" y="190.2" width="60" height="1.6"/>
|
||||
<path class="head" d="M696 186.5 L702 191 L696 195.5 Z"/>
|
||||
<path class="ln" fill="none" d="M112 224 V 252 H 772 V 230"/>
|
||||
<path class="head" d="M768 230 L772 224 L776 230 Z"/>
|
||||
<text class="lbl" x="420" y="244" text-anchor="middle">SEED[]</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 11 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.0.1"
|
||||
version = "2.13.2"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Generate the README flow figures.
|
||||
*
|
||||
* The SPA draws these as Vue + CSS with PrimeIcons webfont glyphs
|
||||
* (`src/manager/components/docs/PipelineDiagram.vue`, framed by
|
||||
* `DocFigure.vue`). A README can run neither: GitHub serves the file through
|
||||
* `<img>`, so there is no component runtime and no icon font.
|
||||
*
|
||||
* So this reproduces that figure in SVG, deriving every value from the real
|
||||
* component rather than approximating it by eye:
|
||||
*
|
||||
* frame --wp-bg-1 / --wp-border, radius --wp-radius-lg (12)
|
||||
* card --wp-bg-2, --wp-border-strong, radius 10, min-width 120,
|
||||
* column layout: icon 16 / name 12 semibold / id 9.5 mono dim
|
||||
* WP card border tinted with --wp-node, icon in --wp-node
|
||||
* feeders pills, --wp-bg-2 + --wp-border, 10.5px, icon in the kind colour
|
||||
* labels 9.5px uppercase, letter-spacing .04em, --wp-text-dim
|
||||
* var chips mono 10.5px, pill, per-chip accent border
|
||||
*
|
||||
* Icons are the REAL PrimeIcons paths from `primeicons/raw-svg` — the same
|
||||
* source the webfont is built from — so the README shows the same glyphs the
|
||||
* app does.
|
||||
*
|
||||
* Both themes ship in one file: GitHub honours `prefers-color-scheme` inside
|
||||
* an SVG served to `<img>`, so the light block below mirrors the SPA's light
|
||||
* theme rather than washing the dark one out.
|
||||
*
|
||||
* Run: node scripts/build-flow-figures.mjs
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ICON_DIR = "node_modules/primeicons/raw-svg";
|
||||
const OUT_DIR = "public/images/docs";
|
||||
|
||||
/* ── tokens, lifted from src/components/shared/theme.css + manager tokens ── */
|
||||
const DARK = {
|
||||
frameBg: "#1e1e1e", frameBd: "#3a3a3a",
|
||||
cardBg: "#232323", cardBd: "#4a4a4a",
|
||||
node: "#2dd4bf",
|
||||
text: "#dddddd", muted: "#aaaaaa", dim: "#666666",
|
||||
wildcard: "#a78bfa", fixed: "#22d3ee", combine: "#34d399",
|
||||
derivation: "#fbbf24", constraint: "#f472b6",
|
||||
chipA: "#c4b5fd", chipAb: "#8b5cf6",
|
||||
chipB: "#67e8f9", chipBb: "#22d3ee",
|
||||
chipC: "#86efac", chipCb: "#34d399",
|
||||
};
|
||||
const LIGHT = {
|
||||
frameBg: "#f5f5f5", frameBd: "#b0b0b0",
|
||||
cardBg: "#ffffff", cardBd: "#999999",
|
||||
node: "#0f766e",
|
||||
text: "#1a1a1a", muted: "#555555", dim: "#888888",
|
||||
wildcard: "#6d28d9", fixed: "#1d4ed8", combine: "#065f46",
|
||||
derivation: "#92400e", constraint: "#9d174d",
|
||||
chipA: "#6d28d9", chipAb: "#8b5cf6",
|
||||
chipB: "#1d4ed8", chipBb: "#22d3ee",
|
||||
chipC: "#065f46", chipCb: "#34d399",
|
||||
};
|
||||
|
||||
const SANS = "'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
|
||||
const MONO = "'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace";
|
||||
|
||||
/** Rough advance width. Good enough to size pills; every label is centred, so
|
||||
* a few px of slop never misaligns text against its box. */
|
||||
const textW = (s, px, mono = false) => s.length * px * (mono ? 0.6 : 0.52);
|
||||
|
||||
/** Inner markup of a PrimeIcon, scaled to `size` and centred on (cx, cy).
|
||||
*
|
||||
* Colour comes from the wrapping `<g fill="currentColor">`, NOT from rewriting
|
||||
* each path. Most PrimeIcons declare no `fill` at all (only `sparkles` does),
|
||||
* so a rewrite-what-exists approach leaves them at SVG's default black — which
|
||||
* is exactly how every node icon came out black while the feeder pills, whose
|
||||
* icons happened to carry fills, coloured correctly.
|
||||
*
|
||||
* `fill="none"` is preserved: on the icons that use it, it marks the part that
|
||||
* is deliberately hollow, and filling it would blob the glyph.
|
||||
*/
|
||||
function icon(name, cx, cy, size) {
|
||||
const raw = fs.readFileSync(path.join(ICON_DIR, `${name}.svg`), "utf8");
|
||||
const inner = raw
|
||||
.replace(/^[\s\S]*?<svg[^>]*>/, "")
|
||||
.replace(/<\/svg>\s*$/, "")
|
||||
.replace(/\s(?:width|height)="[^"]*"/g, "")
|
||||
// Drop concrete fills so the group's currentColor wins; keep `none`.
|
||||
.replace(/\sfill="(?!none)[^"]*"/g, "")
|
||||
.replace(/\sstroke="(?!none)[^"]*"/g, ' stroke="currentColor"')
|
||||
.trim();
|
||||
const s = size / 24;
|
||||
return `<g fill="currentColor" transform="translate(${cx - size / 2} ${cy - size / 2}) scale(${s})">${inner}</g>`;
|
||||
}
|
||||
|
||||
/** A node card. Vertical, centred: icon, name, mono subtitle — the SPA's
|
||||
* `.wp-doc-diagram__node-box`. `wp` cards take the teal node accent. */
|
||||
function card(cx, top, label, sub, iconName, kind = "wp") {
|
||||
const w = Math.max(124, textW(label, 12) + 34, textW(sub, 9.5, true) + 26);
|
||||
const h = 66;
|
||||
const x = cx - w / 2;
|
||||
return {
|
||||
w, h,
|
||||
svg: [
|
||||
`<rect class="card card--${kind}" x="${x}" y="${top}" width="${w}" height="${h}" rx="10"/>`,
|
||||
`<g class="ico ico--${kind}">${icon(iconName, cx, top + 19, 16)}</g>`,
|
||||
`<text class="nm" x="${cx}" y="${top + 43}" text-anchor="middle">${label}</text>`,
|
||||
`<text class="id" x="${cx}" y="${top + 57}" text-anchor="middle">${sub}</text>`,
|
||||
].join("\n "),
|
||||
};
|
||||
}
|
||||
|
||||
/** Labelled connector, matching `.wp-doc-diagram__arrow`: uppercase label
|
||||
* above a hairline that ends in a small solid head. */
|
||||
function connector(x1, x2, y, label) {
|
||||
const mid = (x1 + x2) / 2;
|
||||
return [
|
||||
label ? `<text class="lbl" x="${mid}" y="${y - 9}" text-anchor="middle">${label}</text>` : "",
|
||||
// Gradient fill, not a stroke: the SPA's `.wp-doc-diagram__arrow-ln` fades
|
||||
// in and out at both ends so a connector reads as a flow rather than a
|
||||
// drawn border. It has to be a RECT — a horizontal <line> has a zero-height
|
||||
// bounding box, so an objectBoundingBox gradient on it collapses and the
|
||||
// line disappears entirely, leaving a floating arrowhead.
|
||||
`<rect fill="url(#flow)" x="${x1}" y="${y - 0.8}" width="${x2 - 6 - x1}" height="1.6"/>`,
|
||||
`<path class="head" d="M${x2 - 6} ${y - 4.5} L${x2} ${y} L${x2 - 6} ${y + 4.5} Z"/>`,
|
||||
].join("\n ");
|
||||
}
|
||||
|
||||
/** Pill row of the five module kinds — `.wp-doc-diagram__feeder`. */
|
||||
function feeders(cx, y) {
|
||||
const items = [
|
||||
["sparkles", "Wildcard", "wildcard"],
|
||||
["tag", "Fixed Values", "fixed"],
|
||||
["link", "Combine", "combine"],
|
||||
["arrow-right-arrow-left", "Derivation", "derivation"],
|
||||
["filter", "Constraint", "constraint"],
|
||||
];
|
||||
const gap = 6;
|
||||
const pills = items.map(([ico, label, kind]) => {
|
||||
const w = 9 + 10 + 6 + textW(label, 10.5) + 9;
|
||||
return { ico, label, kind, w };
|
||||
});
|
||||
const total = pills.reduce((a, p) => a + p.w, 0) + gap * (pills.length - 1);
|
||||
let x = cx - total / 2;
|
||||
const out = pills.map((p) => {
|
||||
const s = [
|
||||
`<rect class="pill" x="${x}" y="${y}" width="${p.w}" height="21" rx="10.5"/>`,
|
||||
`<g class="ico--${p.kind}">${icon(p.ico, x + 15, y + 10.5, 10)}</g>`,
|
||||
`<text class="feeder" x="${x + 24}" y="${y + 14}">${p.label}</text>`,
|
||||
].join("\n ");
|
||||
x += p.w + gap;
|
||||
return s;
|
||||
});
|
||||
return out.join("\n ");
|
||||
}
|
||||
|
||||
/** The `$vars` stage: chips over a small uppercase caption. */
|
||||
function varBag(cx, top) {
|
||||
const chips = [["$subject", "a"], ["$style", "b"], ["$lighting", "c"]];
|
||||
const gap = 5;
|
||||
const sized = chips.map(([t, v]) => ({ t, v, w: textW(t, 10.5, true) + 14 }));
|
||||
// Two rows, mirroring the SPA's 150px-capped wrapping bag.
|
||||
const row1 = sized.slice(0, 2);
|
||||
const row2 = sized.slice(2);
|
||||
const line = (items, y) => {
|
||||
const total = items.reduce((a, c) => a + c.w, 0) + gap * (items.length - 1);
|
||||
let x = cx - total / 2;
|
||||
return items.map((c) => {
|
||||
const s = [
|
||||
`<rect class="chip chip--${c.v}" x="${x}" y="${y}" width="${c.w}" height="19" rx="9.5"/>`,
|
||||
`<text class="chipt chipt--${c.v}" x="${x + c.w / 2}" y="${y + 13}" text-anchor="middle">${c.t}</text>`,
|
||||
].join("\n ");
|
||||
x += c.w + gap;
|
||||
return s;
|
||||
}).join("\n ");
|
||||
};
|
||||
return [
|
||||
line(row1, top),
|
||||
line(row2, top + 24),
|
||||
`<text class="varcap" x="${cx}" y="${top + 62}" text-anchor="middle">RESOLVED $VARS</text>`,
|
||||
].join("\n ");
|
||||
}
|
||||
|
||||
function themeBlock(t, prefix = "") {
|
||||
return `
|
||||
${prefix}.frame { fill: ${t.frameBg}; stroke: ${t.frameBd}; }
|
||||
${prefix}.card { fill: ${t.cardBg}; stroke: ${t.cardBd}; }
|
||||
${prefix}.card--wp { stroke: ${t.node}; stroke-opacity: .55; }
|
||||
${prefix}.ico--wp { color: ${t.node}; }
|
||||
${prefix}.ico--ext { color: ${t.muted}; }
|
||||
${prefix}.ico--wildcard { color: ${t.wildcard}; }
|
||||
${prefix}.ico--fixed { color: ${t.fixed}; }
|
||||
${prefix}.ico--combine { color: ${t.combine}; }
|
||||
${prefix}.ico--derivation { color: ${t.derivation}; }
|
||||
${prefix}.ico--constraint { color: ${t.constraint}; }
|
||||
${prefix}.pill { fill: ${t.cardBg}; stroke: ${t.frameBd}; }
|
||||
${prefix}.feeder { fill: ${t.muted}; }
|
||||
${prefix}.nm { fill: ${t.text}; }
|
||||
${prefix}.id, ${prefix}.lbl, ${prefix}.cap, ${prefix}.varcap { fill: ${t.dim}; }
|
||||
${prefix}.ln { stroke: ${t.cardBd}; }
|
||||
${prefix}.wash { stop-color: ${t.node}; }
|
||||
${prefix}.flow0 { stop-color: ${t.cardBd}; }
|
||||
${prefix}.head { fill: ${t.cardBd}; }
|
||||
${prefix}.chip { fill: ${t.cardBg}; }
|
||||
${prefix}.chip--a { stroke: ${t.chipAb}; stroke-opacity: .4; }
|
||||
${prefix}.chip--b { stroke: ${t.chipBb}; stroke-opacity: .4; }
|
||||
${prefix}.chip--c { stroke: ${t.chipCb}; stroke-opacity: .4; }
|
||||
${prefix}.chipt--a { fill: ${t.chipA}; }
|
||||
${prefix}.chipt--b { fill: ${t.chipB}; }
|
||||
${prefix}.chipt--c { fill: ${t.chipC}; }`;
|
||||
}
|
||||
|
||||
function shell(w, h, label, body) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="${label}">
|
||||
<style><![CDATA[
|
||||
.frame, .card, .pill, .chip { stroke-width: 1; }
|
||||
.cap { font: 400 10px ${SANS}; letter-spacing: .06em; }
|
||||
.feeder { font: 400 10.5px ${SANS}; }
|
||||
.nm { font: 600 12px ${SANS}; }
|
||||
.id { font: 400 9.5px ${MONO}; }
|
||||
.lbl, .varcap { font: 400 9.5px ${SANS}; letter-spacing: .05em; }
|
||||
.chipt { font: 400 10.5px ${MONO}; }
|
||||
${themeBlock(DARK)}
|
||||
@media (prefers-color-scheme: light) {${themeBlock(LIGHT, " ")}
|
||||
}
|
||||
]]></style>
|
||||
<defs>
|
||||
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
|
||||
width by 100 percent of the HEIGHT, anchored top-left — so it spans
|
||||
the whole panel. Squashing it by the aspect ratio instead pins it to
|
||||
the corner and leaves the rest looking flat. -->
|
||||
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
|
||||
<stop class="wash" offset="0" stop-opacity="0.10"/>
|
||||
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
|
||||
<stop class="wash" offset="1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.25" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.2" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect class="frame" x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="12"/>
|
||||
<rect x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="12" fill="url(#wash)"/>
|
||||
${body}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── figure 1 — what the pipeline does ─────────────────────────────────── */
|
||||
function buildIntro() {
|
||||
const W = 880;
|
||||
const H = 202;
|
||||
const cx = W / 2;
|
||||
const rowTop = 104;
|
||||
const midY = rowTop + 33;
|
||||
|
||||
const ctx = card(112, rowTop, "WP Context", "rolls + resolves", "sitemap");
|
||||
const asm = card(566, rowTop, "Prompt Assembler", '"A $style portrait…"', "align-left");
|
||||
const clip = card(772, rowTop, "CLIP Encode", "conditioning", "bolt", "ext");
|
||||
const varsCx = 348;
|
||||
|
||||
const body = [
|
||||
`<text class="cap" x="${cx}" y="30" text-anchor="middle">MODULES INSIDE THE CONTEXT</text>`,
|
||||
feeders(cx, 40),
|
||||
`<g class="ico--ext">${icon("angle-down", cx, 78, 12)}</g>`,
|
||||
ctx.svg,
|
||||
connector(112 + ctx.w / 2 + 8, varsCx - 78, midY, "EMITS"),
|
||||
varBag(varsCx, rowTop - 2),
|
||||
connector(varsCx + 78, 566 - asm.w / 2 - 8, midY, "FILLS"),
|
||||
asm.svg,
|
||||
connector(566 + asm.w / 2 + 8, 772 - clip.w / 2 - 8, midY, "PROMPT"),
|
||||
clip.svg,
|
||||
].join("\n ");
|
||||
|
||||
return shell(
|
||||
W,
|
||||
H,
|
||||
"Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits resolved dollar-variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning",
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── figure 2 — looping a chain ────────────────────────────────────────────
|
||||
* The old figure ran the assembled prompt straight into KSampler. It does not
|
||||
* go there: text is encoded first and only conditioning reaches the sampler.
|
||||
* Seeds are the one thing that DOES arrive directly, so they get their own
|
||||
* lane BELOW the text path rather than cutting through it. */
|
||||
function buildLoop() {
|
||||
const W = 880;
|
||||
const H = 286;
|
||||
const cx = W / 2;
|
||||
const r1 = 54;
|
||||
const r2 = 158;
|
||||
const mid1 = r1 + 33;
|
||||
const mid2 = r2 + 33;
|
||||
const laneY = 252;
|
||||
|
||||
const loop = card(112, r1, "WP Context Loop", "N iterations", "refresh");
|
||||
const seeds = card(112, r2, "WP Seed List", "N seeds", "list");
|
||||
const ctx = card(340, r1, "WP Context", "module stack", "sitemap");
|
||||
const asm = card(566, r1, "Prompt Assembler", "N prompts", "align-left");
|
||||
const clip = card(566, r2, "CLIP Encode", "N conditionings", "bolt", "ext");
|
||||
const ks = card(772, r2, "KSampler", "N images", "image", "ext");
|
||||
|
||||
const body = [
|
||||
`<text class="cap" x="${cx}" y="30" text-anchor="middle">BOTH LISTS FAN OUT IN LOCKSTEP — ITERATION N MEETS SEED N</text>`,
|
||||
loop.svg, seeds.svg, ctx.svg, asm.svg, clip.svg, ks.svg,
|
||||
connector(112 + loop.w / 2 + 8, 340 - ctx.w / 2 - 8, mid1, "CONTEXT"),
|
||||
// WP_ContextLoop has a `loop_config` side-output and WP_SeedList takes it
|
||||
// as an optional input, so the seed list mirrors the loop's count,
|
||||
// strategy and base seed. That link is what makes the caption's "lockstep"
|
||||
// true; without it the two read as unrelated sources.
|
||||
`<text class="lbl" x="122" y="${(r1 + 66 + r2) / 2 + 4}">LOOP_CONFIG</text>`,
|
||||
`<rect fill="url(#flowv)" x="111.2" y="${r1 + 66}" width="1.6" height="${r2 - 6 - (r1 + 66)}"/>`,
|
||||
`<path class="head" d="M108 ${r2 - 6} L112 ${r2} L116 ${r2 - 6} Z"/>`,
|
||||
connector(340 + ctx.w / 2 + 8, 566 - asm.w / 2 - 8, mid1, "$VARS"),
|
||||
// assembler drops straight down into the encoder
|
||||
`<text class="lbl" x="576" y="141">PROMPT</text>`,
|
||||
`<rect fill="url(#flowv)" x="565.2" y="${r1 + 66}" width="1.6" height="${r2 - 6 - (r1 + 66)}"/>`,
|
||||
`<path class="head" d="M562 ${r2 - 6} L566 ${r2} L570 ${r2 - 6} Z"/>`,
|
||||
connector(566 + clip.w / 2 + 8, 772 - ks.w / 2 - 8, mid2, "COND"),
|
||||
// seeds take their own lane under the text path and rise into the sampler
|
||||
`<path class="ln" fill="none" d="M112 ${r2 + 66} V ${laneY} H 772 V ${r2 + 72}"/>`,
|
||||
`<path class="head" d="M768 ${r2 + 72} L772 ${r2 + 66} L776 ${r2 + 72} Z"/>`,
|
||||
`<text class="lbl" x="420" y="${laneY - 8}" text-anchor="middle">SEED[]</text>`,
|
||||
].join("\n ");
|
||||
|
||||
return shell(
|
||||
W,
|
||||
H,
|
||||
"WP Context Loop drives WP Context and the Prompt Assembler to produce N prompts, CLIP Encode turns them into conditioning for KSampler, and WP Seed List supplies the matching seed for each iteration directly to KSampler",
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(OUT_DIR, "flow-intro.svg"), buildIntro());
|
||||
fs.writeFileSync(path.join(OUT_DIR, "flow-loop.svg"), buildLoop());
|
||||
console.log("wrote flow-intro.svg + flow-loop.svg");
|
||||
@@ -144,7 +144,110 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// in onUpdate/onValueRestored/microtask). Net ~0.5 KB gzip; 2 KB
|
||||
// headroom buffer to avoid the "17 bytes from budget" tightness from
|
||||
// the previous bump.
|
||||
const TOTAL_LIMIT = 395 * 1024; // 395 KB
|
||||
// Bumped 395 -> 405 KB on 2026-06-07 for SP1 Chunk G (in-graph multi-tag
|
||||
// pool editor). PoolSection gains grouped quick pills + an ƒ(x) Advanced
|
||||
// boolean-expression editor; OptionRow gains per-tag CATEGORY chips; and
|
||||
// the shared engine-parity subcat_filter parser (parse/matches/readsAs)
|
||||
// is now imported into the wildcard graph editor chunk. ~4 KB net gzip
|
||||
// across the lazy ContextWidget chunk; entry stays put. Approved with the
|
||||
// SP1 multi-tag + boolean-filter feature (docs/superpowers/specs/
|
||||
// 2026-06-06-wildcard-multi-subcategory-boolean-filter-design.md §4.2).
|
||||
// Bumped 405 -> 430 KB on 2026-06-11 for SP3 constraint reach UI headroom.
|
||||
// SP3 Task 13 lands the TS combineConstraintFactor mirror (+ shared corpus
|
||||
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
|
||||
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
|
||||
// the SP3 constraint feature.
|
||||
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
|
||||
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
|
||||
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
|
||||
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
|
||||
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
|
||||
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
|
||||
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
|
||||
// exceptions trash, across all 6 edit modals.
|
||||
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
|
||||
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
|
||||
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
|
||||
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
|
||||
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
|
||||
// across the ContextWidget + conflicts chunks; entry stays put.
|
||||
// Bumped 432 -> 435 KB on 2026-06-22 for the canvas constraint-matrix grouping
|
||||
// parity. MatrixSection (the ComfyUI instance-modal grid) gains the SPA's
|
||||
// grouped-axis layout — column bands, row header chips, solo eyebrow, the
|
||||
// labelled "uncategorized" bucket, and frozen headers + both-axis scroll —
|
||||
// plus the shared components/shared/matrix-axis module the SPA matrix now
|
||||
// imports too. ~1.1 KB net gzip in the lazy ContextWidget chunk; entry stays
|
||||
// put. Approved with the matrix-parity feature request.
|
||||
// Bumped 435 -> 442 KB on 2026-06-23 for per-iteration seeds modal
|
||||
// (T8+T9). ContextLoopWidget gains the SeedListModal + SeedLockRow lazy
|
||||
// body (v-if gated) — adds ~5.5 KB net gzip to the ContextLoopWidget
|
||||
// lazy chunk. SeedListWidget gains a matching button in T9. Headroom
|
||||
// covers both T8 and T9 landings without per-task bump friction.
|
||||
// Bumped 442 -> 443 KB on 2026-06-23 for T9 (SeedListWidget seeds
|
||||
// button). The SeedListWidget chunk grew ~0.7 KB gzip adding the button
|
||||
// template + ref/computed script + seedbtn CSS; T8's headroom estimate
|
||||
// was tight. Minimal bump to the actual measured ceiling.
|
||||
// Bumped 443 -> 444 KB on 2026-06-23 for T12 (seed-list preview
|
||||
// effective base/count/strategy from wired loop). Adds preview.ts pure
|
||||
// resolver + graph-walk glue in seed_list.ts + previewStrategy prop in
|
||||
// SeedListWidget.vue. ~0.4 KB net gzip in the seed_list widget chunk.
|
||||
// Bumped 444 -> 445 KB on 2026-06-23 for T16 (seed modal paste-to-replace).
|
||||
// Adds parseSeedLocks in seed-derive + pasteAll/Paste button in SeedListModal.
|
||||
// Measured 454900 bytes (244 over the 444 ceiling); minimal bump.
|
||||
// Bumped 445 -> 446 KB on 2026-06-24 for UI-C (wire ContextWidget frame edits).
|
||||
// Adds imports of frame-cursor / frame-overrides / INSTANCE_FIELDS_PER_KIND into
|
||||
// the ContextWidget lazy chunk + the merge-on-open / write-redirect logic.
|
||||
// Measured 455772 bytes (92 over the 445 ceiling); minimal bump.
|
||||
// Bumped 446 -> 447 KB on 2026-06-24 for RF2 (frame-aware row quick-lock).
|
||||
// Adds toggleFrameLock helper in frame-overrides.ts + effectiveLockedSeed +
|
||||
// frame branch in toggleLockOnCard + frame-aware isLocked. Net ~168 bytes
|
||||
// gzip in the lazy ContextWidget chunk; entry stays put.
|
||||
// Bumped 447 -> 448 KB on 2026-06-24 for the symmetric per-frame enable
|
||||
// redesign (a `frame_enabled` override map replaces the `disabled_frames`
|
||||
// blocklist so a base-off module can be turned ON for select frames). Adds
|
||||
// frameEnabledAt / effectiveEnabled / frameEnableOverride / toggleFrameEnabled
|
||||
// to frame-overrides.ts, the effective-enabled checkbox + bidirectional
|
||||
// on-#k / off-#k badge in ModuleRow, and the .wp-mod-badge--on CSS. Measured
|
||||
// 457868 bytes (140 over the 447 ceiling); minimal bump.
|
||||
// Bumped 448 -> 449 KB on 2026-06-25 for the per-frame seed-lock correctness
|
||||
// work: the Loop's derived-series capture (loop_seeds UI payload + onExecuted
|
||||
// store + frameSeedReader preference, so the lock pins the PREVIOUS run's
|
||||
// per-frame seed under control_after_generate=randomize) plus the modal
|
||||
// Lock-button resolver (lock-seed-ctx provide/inject into SeedLockControls).
|
||||
// Landed exactly at the 448 ceiling (458752/458752); bump for headroom.
|
||||
// Bumped 449 -> 450 KB on 2026-06-28 for the row context-menu Hold toggle.
|
||||
// Adds HOLD_KINDS + isHoldable + toggleHold to ContextWidget plus the quick
|
||||
// "Hold across run" / "Release hold" menu item (2-click seed_scope toggle,
|
||||
// base-only with a frame-active disabled hint) in the lazy ContextWidget
|
||||
// chunk; entry stays put. Measured 459909 bytes (133 over the 449 ceiling);
|
||||
// minimal bump.
|
||||
// Bumped 450 -> 453 KB on 2026-07-21 for loop bypass frames (#13): the
|
||||
// `bypass_frames` config field + the SeedLockRow bypass toggle, the
|
||||
// SeedListModal opt-in bypass column, and the ContextLoopWidget "N bypassed"
|
||||
// badge. Config field alone landed 34 bytes over 450; the ~3 KB headroom
|
||||
// covers the UI additions in the same feature.
|
||||
// Bumped 453 -> 461 KB on 2026-07-23 for the chip-UX + library-linkage feature
|
||||
// set (issues #1-#9). Workstream B (RefChip hover card: uuid + filter reads-as
|
||||
// + live "N of M options match" from the extended preview-resolver) landed the
|
||||
// canvas within 10 bytes of the 453 ceiling; the +8 KB gives the remaining
|
||||
// workstreams D (library re-link UI) and E (nested-ref propagation) room in the
|
||||
// same approved feature set. Explicitly approved.
|
||||
// Bumped 461 -> 468 KB on 2026-07-30 for the QoL sweep (P0+P1). The bulk is UI
|
||||
// that had to grow: export/import row detail per entity kind, the category
|
||||
// IconPicker, the feedback modal, cross-bucket search, collapsible tag pools,
|
||||
// and finally `collectUpstreamProducers` + the var-chip producer card, which
|
||||
// tipped it 180 bytes over. Explicitly approved by the maintainer, who asked
|
||||
// for the budget to be raised as needed rather than features trimmed to fit.
|
||||
// Bumped 468 -> 490 KB on 2026-08-02 for the `@`/`$` autocomplete + sub-category
|
||||
// filter redesign. The popover rows carry structure now (icon, uuid, option /
|
||||
// axis / tag facts, and for `$` the producer line and override badge) instead
|
||||
// of one line of text, and the shared filter panel that follows adds the
|
||||
// axis-grouped palette with collapse + search, the live match count and the
|
||||
// zero-match / unknown-term states. Sized with headroom deliberately: the
|
||||
// previous bump left 179 bytes free, which meant every subsequent change had
|
||||
// to argue with the gate. Explicitly approved by the maintainer, who asked for
|
||||
// the budget to be raised as needed rather than features trimmed to fit.
|
||||
const TOTAL_LIMIT = 490 * 1024; // 490 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post a release announcement to Discord.
|
||||
*
|
||||
* Derives the message from the release notes that already exist — there is
|
||||
* nothing extra to write. Run by `.github/workflows/discord-announce.yml` when
|
||||
* a release is published, and runnable by hand for the catch-up case.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 # dry run
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 --post # → staff
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 --post --channel public
|
||||
* node scripts/discord-announce.mjs --since v2.10.2 --post # catch-up
|
||||
* node scripts/discord-announce.mjs --notes-file docs/release-notes/next.md \
|
||||
* --version v2.12.0 # preview
|
||||
*
|
||||
* DRY RUN IS THE DEFAULT. `--post` is required to send anything, so previewing
|
||||
* an announcement can never accidentally publish one.
|
||||
*
|
||||
* Environment:
|
||||
* DISCORD_WEBHOOK_STAFF staff / pre-announcement channel (the default)
|
||||
* DISCORD_WEBHOOK_PUBLIC public announcement channel (--channel public only)
|
||||
* DISCORD_WEBHOOK_URL legacy fallback for the staff webhook
|
||||
* DISCORD_ROLE_ID optional; pinged as <@&ID>, PUBLIC channel only
|
||||
* DISCORD_USERNAME optional; overrides the posting name
|
||||
* DISCORD_AVATAR_URL optional; overrides the posting avatar (raster only)
|
||||
* GITHUB_TOKEN optional; raises the API rate limit for --since
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { buildPayload, buildRollupPayload, normalizeRoleId } from "./lib/discord-notes.mjs";
|
||||
|
||||
const REPO = "DumiFlex/ComfyUI-Wildcard-Pipeline";
|
||||
|
||||
/**
|
||||
* Two channels, deliberately asymmetric.
|
||||
*
|
||||
* `staff` posts automatically on every release and carries a link to publish
|
||||
* the same message onward — it is the review step. `public` only ever runs
|
||||
* from a deliberate manual dispatch, so a release can never announce itself to
|
||||
* everyone without someone having read it first.
|
||||
*/
|
||||
const CHANNELS = {
|
||||
staff: ["DISCORD_WEBHOOK_STAFF", "DISCORD_WEBHOOK_URL"],
|
||||
public: ["DISCORD_WEBHOOK_PUBLIC"],
|
||||
};
|
||||
|
||||
function webhookFor(channel) {
|
||||
for (const name of CHANNELS[channel]) {
|
||||
if (process.env[name]) return { url: process.env[name], via: name };
|
||||
}
|
||||
return { url: null, via: CHANNELS[channel][0] };
|
||||
}
|
||||
|
||||
function arg(name, fallback = undefined) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const next = process.argv[i + 1];
|
||||
return next && !next.startsWith("--") ? next : true;
|
||||
}
|
||||
|
||||
function ghHeaders() {
|
||||
const h = { Accept: "application/vnd.github+json", "User-Agent": "wp-discord-announce" };
|
||||
if (process.env.GITHUB_TOKEN) h.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function fetchRelease(tag) {
|
||||
const url = tag
|
||||
? `https://api.github.com/repos/${REPO}/releases/tags/${tag}`
|
||||
: `https://api.github.com/repos/${REPO}/releases/latest`;
|
||||
const res = await fetch(url, { headers: ghHeaders() });
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status} fetching ${tag ?? "latest"}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Releases newer than `sinceTag`, newest first. Drafts and prereleases skipped. */
|
||||
async function fetchReleasesSince(sinceTag) {
|
||||
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100`, {
|
||||
headers: ghHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status} listing releases`);
|
||||
const all = (await res.json()).filter((r) => !r.draft && !r.prerelease);
|
||||
const idx = all.findIndex((r) => r.tag_name === sinceTag);
|
||||
if (idx === -1) throw new Error(`No release tagged ${sinceTag}`);
|
||||
return all.slice(0, idx);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const post = arg("post", false) === true;
|
||||
const channel = arg("channel", "staff");
|
||||
if (channel !== "staff" && channel !== "public") {
|
||||
console.error(`Unknown --channel "${channel}". Use staff or public.`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Belt to the workflow's braces. The workflow only passes `--channel public`
|
||||
// on a manual dispatch, but a release event reaching this line with public
|
||||
// selected would announce to everyone with nobody having read it.
|
||||
if (channel === "public" && process.env.GITHUB_EVENT_NAME === "release") {
|
||||
console.error("Refusing to post to the public channel from a release event.");
|
||||
process.exit(1);
|
||||
}
|
||||
// Only the public post pings. A role ping in the staff channel on every
|
||||
// release is noise in a room that already exists to watch releases.
|
||||
const rawRole = channel === "public" ? process.env.DISCORD_ROLE_ID : undefined;
|
||||
const roleId = normalizeRoleId(rawRole);
|
||||
// `buildPayload` drops a malformed id either way, but silence would leave
|
||||
// someone wondering why the ping never fired.
|
||||
if (rawRole && rawRole.trim() && !roleId) {
|
||||
console.warn(
|
||||
`DISCORD_ROLE_ID is not a valid role id (${JSON.stringify(rawRole)}) — posting without a ping.`,
|
||||
);
|
||||
}
|
||||
// Undefined falls through to the defaults in discord-notes.mjs — an empty
|
||||
// env var must not blank the identity and hand the post back to the
|
||||
// webhook's own name.
|
||||
const username = process.env.DISCORD_USERNAME || undefined;
|
||||
const avatarUrl = process.env.DISCORD_AVATAR_URL || undefined;
|
||||
const since = arg("since");
|
||||
const notesFile = arg("notes-file");
|
||||
let version = arg("version");
|
||||
|
||||
let payload;
|
||||
|
||||
if (typeof since === "string") {
|
||||
const releases = await fetchReleasesSince(since);
|
||||
if (!releases.length) {
|
||||
console.error(`Nothing published after ${since} — nothing to announce.`);
|
||||
process.exit(0);
|
||||
}
|
||||
payload = buildRollupPayload({
|
||||
releases: releases.map((r) => ({ version: r.tag_name, body: r.body ?? "" })),
|
||||
sinceVersion: since,
|
||||
releaseUrl: releases[0].html_url,
|
||||
roleId,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
} else if (typeof notesFile === "string") {
|
||||
// Local preview against unreleased notes. No network, so the compare link
|
||||
// is unknown and simply omitted.
|
||||
payload = buildPayload({
|
||||
version: version || "(unreleased)",
|
||||
body: readFileSync(notesFile, "utf8"),
|
||||
roleId,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
} else {
|
||||
const rel = await fetchRelease(typeof version === "string" ? version : undefined);
|
||||
version = rel.tag_name;
|
||||
payload = buildPayload({
|
||||
version,
|
||||
body: rel.body ?? "",
|
||||
releaseUrl: rel.html_url,
|
||||
roleId,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
console.log("--- DRY RUN (pass --post to send) ---\n");
|
||||
// Identity is part of what a preview is for: it is the thing that was
|
||||
// wrong on the first real post, and it is invisible in the embed body.
|
||||
console.log(`channel: ${channel}`);
|
||||
console.log(`posting as: ${payload.username}`);
|
||||
console.log(`avatar: ${payload.avatar_url}`);
|
||||
console.log("");
|
||||
console.log(payload.embeds[0].title);
|
||||
console.log("-".repeat(60));
|
||||
console.log(payload.embeds[0].description);
|
||||
console.log("-".repeat(60));
|
||||
console.log(`${payload.embeds[0].description.length} / 4096 chars`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { url: webhook, via } = webhookFor(channel);
|
||||
if (!webhook) {
|
||||
console.error(`${via} is not set — refusing to post to the ${channel} channel.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const res = await fetch(`${webhook}?wait=true`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Discord ${res.status}: ${await res.text()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Announced ${payload.embeds[0].title} → ${channel}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Generate engine-parity fixtures for the community validator suite.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
The community-web TS validators (`src/validators/`) are a hand-authored
|
||||
re-implementation of the engine's payload shapes. Re-implementations
|
||||
drift: fixed_values was published for weeks as `entries/{variable_name}`
|
||||
when the engine has always used `values/{id,name,value}`; wildcard
|
||||
options omitted the real `is_null` field; etc. Every drift surfaced as a
|
||||
publish failure in front of a user.
|
||||
|
||||
This script makes the ENGINE the single source of truth. For each module
|
||||
subtype it builds a sample payload, runs it through that subtype's own
|
||||
`Handler.validate_payload` (the engine authority -- raises if the sample
|
||||
is not engine-valid), and dumps the engine-row shape to a committed
|
||||
fixture. The companion test `src/validators/__tests__/engine-parity.test.ts`
|
||||
asserts the STRICT community validator accepts every fixture.
|
||||
|
||||
Result: if the engine shape changes (a handler gains/renames a field) the
|
||||
sample stops validating here and this script fails; if the TS validator
|
||||
drifts from the engine, the parity test fails. Drift is caught at CI time
|
||||
instead of in front of a publisher.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
python scripts/dump_engine_shapes.py
|
||||
|
||||
Run it whenever an engine module shape changes, then commit the updated
|
||||
fixtures. Exit 0 = all samples engine-valid + written; exit 1 = a sample
|
||||
is no longer engine-valid (fix the sample to match the new shape).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Make `engine` importable when run as a bare script (mirrors the
|
||||
# tests/conftest.py sys.path shim).
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from engine.modules.combine_handler import CombineHandler # noqa: E402
|
||||
from engine.modules.constraint_handler import ConstraintHandler # noqa: E402
|
||||
from engine.modules.derivation_handler import DerivationHandler # noqa: E402
|
||||
from engine.modules.fixed_values_handler import FixedValuesHandler # noqa: E402
|
||||
from engine.modules.wildcard_handler import WildcardHandler # noqa: E402
|
||||
|
||||
# Output dir, relative to repo root (this file lives in scripts/).
|
||||
_OUT_DIR = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "src" / "validators" / "fixtures" / "engine-parity"
|
||||
)
|
||||
|
||||
# Each entry: (handler, type-specific payload). The payload must pass the
|
||||
# handler's validate_payload -- keep these as the MINIMAL real shape per
|
||||
# subtype, including fields that the strip-tolerant validator must
|
||||
# preserve (e.g. wildcard `is_null`).
|
||||
_SAMPLES: dict[str, tuple[type, dict]] = {
|
||||
"wildcard": (
|
||||
WildcardHandler,
|
||||
{
|
||||
"var_binding": "mood",
|
||||
"sub_categories": ["soft", "bold"],
|
||||
"options": [
|
||||
{"id": "opt00001", "value": "serene", "weight": 1, "sub_category": "soft"},
|
||||
# Null option: the is_null contract. Must round-trip
|
||||
# through the validator or install rebuilds it as an
|
||||
# invalid empty-value option.
|
||||
{"id": "opt00002", "value": "", "weight": 1, "sub_category": None, "is_null": True},
|
||||
],
|
||||
},
|
||||
),
|
||||
"fixed_values": (
|
||||
FixedValuesHandler,
|
||||
{"values": [{"id": "val00001", "name": "style", "value": "oil painting"}]},
|
||||
),
|
||||
"combine": (
|
||||
CombineHandler,
|
||||
{"template": "$mood $subject", "output_var": "scene", "input_vars": ["mood", "subject"]},
|
||||
),
|
||||
"derivation": (
|
||||
DerivationHandler,
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "rule0001",
|
||||
"branches": [
|
||||
{
|
||||
"condition": {
|
||||
"var": "mood", "op": "equals", "value": "dramatic",
|
||||
},
|
||||
"action": {
|
||||
"target_var": "accent", "mode": "replace",
|
||||
"value": "cinematic lighting",
|
||||
},
|
||||
},
|
||||
],
|
||||
"else": {
|
||||
"action": {
|
||||
"target_var": "accent", "mode": "replace",
|
||||
"value": "soft lighting",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
"constraint": (
|
||||
ConstraintHandler,
|
||||
{
|
||||
"source_wildcard_id": "src00001",
|
||||
"target_wildcard_id": "tgt00001",
|
||||
"exceptions": [],
|
||||
"matrix": {"soft": {"bold": {"mode": "boost", "factor": 3}}},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _engine_row(type_id: str, payload: dict) -> dict:
|
||||
"""Wrap a type-specific payload in the canonical engine-row shape
|
||||
the publisher ships (see manager/import-export/single-row-publish.ts:
|
||||
buildModulePublishable)."""
|
||||
return {
|
||||
"id": f"parity{type_id[:2]}",
|
||||
"type": type_id,
|
||||
"name": f"parity-{type_id}",
|
||||
"description": "",
|
||||
"category_id": None,
|
||||
"tags": [],
|
||||
"is_favorite": False,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
failures: list[str] = []
|
||||
for type_id, (handler, payload) in _SAMPLES.items():
|
||||
try:
|
||||
# Engine authority: this raises if the sample is not a valid
|
||||
# payload for the CURRENT engine handler.
|
||||
handler.validate_payload(payload)
|
||||
except Exception as exc: # noqa: BLE001 -- report any validation failure
|
||||
failures.append(f"{type_id}: sample is no longer engine-valid: {exc}")
|
||||
continue
|
||||
row = _engine_row(type_id, payload)
|
||||
out = _OUT_DIR / f"{type_id}.json"
|
||||
out.write_text(json.dumps(row, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"[parity] wrote {out.relative_to(_OUT_DIR.parent.parent.parent)}")
|
||||
|
||||
if failures:
|
||||
print("\n[parity] FAILED -- update the sample(s) to match the engine:", file=sys.stderr)
|
||||
for f in failures:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"[parity] {len(_SAMPLES)} engine-valid fixtures written to {_OUT_DIR}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Release notes → Discord message.
|
||||
*
|
||||
* The announcement is DERIVED, never authored. `docs/release-notes/next.md` is
|
||||
* already written by hand for every release and already is the GitHub release
|
||||
* body — so the only reason a Discord post was a separate chore is that nothing
|
||||
* transformed one into the other. Nothing here asks for new prose.
|
||||
*
|
||||
* Discord is not a markdown renderer in the way GitHub is, and the gaps are
|
||||
* specific rather than general:
|
||||
*
|
||||
* - Masked links `[text](url)` render ONLY inside an embed. In plain message
|
||||
* content they show as literal brackets, which is why the notes go in an
|
||||
* embed and the message content stays a bare ping.
|
||||
* - There are no tables. A pipe table degrades into unreadable pipes.
|
||||
* - `<details>` is HTML; it shows as raw tags.
|
||||
* - Embed description caps at 4096 characters, and the whole payload at 6000.
|
||||
*/
|
||||
|
||||
/** Everything below this marker is the long tail; the SPA modal cuts here too. */
|
||||
const MODAL_CUT = /<!--\s*\/modal\s*-->/i;
|
||||
|
||||
/** Discord's hard cap on a single embed description. */
|
||||
export const EMBED_DESCRIPTION_LIMIT = 4096;
|
||||
|
||||
/** Accent purple, matching `--wp-accent-500` in the manager. */
|
||||
export const BRAND_COLOR = 0x8b5cf6;
|
||||
|
||||
/**
|
||||
* Who the post appears to be from.
|
||||
*
|
||||
* A webhook posts under whatever name and avatar it was created with — the
|
||||
* Discord default is a generic name like "Captain Hook" and a placeholder
|
||||
* image, which is what an announcement went out as. `username` and
|
||||
* `avatar_url` override that PER MESSAGE, so the identity lives here rather
|
||||
* than in a webhook's settings: it stays right if the webhook is ever rotated
|
||||
* or recreated, and it is the same in every channel the automation posts to.
|
||||
*/
|
||||
export const WEBHOOK_USERNAME = "Wildcard Pipeline";
|
||||
|
||||
/**
|
||||
* Discord fetches this server-side, so it has to be a public raster URL — an
|
||||
* SVG will not render, which rules out the repo's `favicon.svg`. Pinned to
|
||||
* `main` rather than a tag so replacing the logo does not need a code change.
|
||||
*/
|
||||
export const WEBHOOK_AVATAR_URL =
|
||||
"https://raw.githubusercontent.com/DumiFlex/ComfyUI-Wildcard-Pipeline/main/public/images/web-app-manifest-512x512.png";
|
||||
|
||||
/**
|
||||
* A role id we are willing to turn into a ping, or `undefined`.
|
||||
*
|
||||
* An unset secret was always safe, but a MALFORMED one was not: any truthy
|
||||
* string went straight into `<@&…>`, so a stray space produced a literal
|
||||
* `<@& >` at the top of the announcement and a pasted mention produced
|
||||
* `<@&<@&123>>`. Both render as visible junk in the most public message the
|
||||
* project sends.
|
||||
*
|
||||
* Accepts a bare snowflake or the `<@&123>` form, since "Copy ID" and copying
|
||||
* the mention text are equally easy mistakes to make. Anything else is
|
||||
* rejected rather than guessed at — a ping that silently does not happen is a
|
||||
* much smaller problem than a broken one that does.
|
||||
*/
|
||||
export function normalizeRoleId(raw) {
|
||||
if (typeof raw !== "string" && typeof raw !== "number") return undefined;
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return undefined;
|
||||
const unwrapped = trimmed.replace(/^<@&(\d+)>$/, "$1");
|
||||
// Discord snowflakes are 17-20 digits today; the range leaves room without
|
||||
// accepting arbitrary numbers.
|
||||
return /^\d{17,20}$/.test(unwrapped) ? unwrapped : undefined;
|
||||
}
|
||||
|
||||
/** Where the manual "publish to the public channel" run is started. */
|
||||
export const DISPATCH_URL =
|
||||
"https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions/workflows/discord-announce.yml";
|
||||
|
||||
/**
|
||||
* Split a release body into the part worth announcing and what is left behind.
|
||||
*
|
||||
* The cut is the same one the in-app update dialog makes, for the same reason:
|
||||
* an announcement is an interruption and should stay short. The tail is not
|
||||
* discarded silently — `tailCount` lets the message say how much it is not
|
||||
* showing, so a big release does not read as a small one.
|
||||
*/
|
||||
export function splitBody(md) {
|
||||
const at = String(md ?? "").search(MODAL_CUT);
|
||||
if (at === -1) return { head: String(md ?? ""), tail: "", tailCount: 0 };
|
||||
const head = md.slice(0, at);
|
||||
// Count against comment-stripped text, NOT details-stripped text. The
|
||||
// "Also in this release" bullets live INSIDE the <details> block, so
|
||||
// stripping it first counts nothing real — while the authoring guidance in
|
||||
// the trailing HTML comment contains bullets of its own that must not be
|
||||
// counted. Getting this backwards reported a 26-change release as 3.
|
||||
const tail = stripComments(md.slice(at));
|
||||
const tailCount = (tail.match(/^\s*[-*]\s+\S/gm) ?? []).length;
|
||||
return { head, tail, tailCount };
|
||||
}
|
||||
|
||||
function stripDetails(md) {
|
||||
return md.replace(/<details[\s\S]*?<\/details>/gi, "");
|
||||
}
|
||||
|
||||
function stripComments(md) {
|
||||
return md.replace(/<!--[\s\S]*?-->/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the header block the GitHub template prepends.
|
||||
*
|
||||
* It is a row of links to the wiki, Discord and the issue tracker. On Discord
|
||||
* the Discord link is noise, and the rest belong in the embed's own fields
|
||||
* rather than buried in the body.
|
||||
*/
|
||||
function stripTemplateHeader(md) {
|
||||
return md
|
||||
.replace(/^\s*##\s*🎉[^\n]*\n/, "")
|
||||
.replace(/^\s*(?:📖|💬|📦|🐛)[^\n]*\n/gm, "");
|
||||
}
|
||||
|
||||
/** Pipe tables have no Discord equivalent; each row becomes a bullet. */
|
||||
function tablesToBullets(md) {
|
||||
const lines = md.split("\n");
|
||||
const out = [];
|
||||
for (const line of lines) {
|
||||
const isRow = /^\s*\|.*\|\s*$/.test(line);
|
||||
if (!isRow) { out.push(line); continue; }
|
||||
// Separator row (|---|---|) carries no content.
|
||||
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
|
||||
const cells = line.trim().slice(1, -1).split("|").map((c) => c.trim());
|
||||
if (!cells.some(Boolean)) continue;
|
||||
out.push(`- ${cells.filter(Boolean).join(" — ")}`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Headings become bold lines.
|
||||
*
|
||||
* Discord does render `#`-style headings now, but at sizes chosen for chat
|
||||
* rather than for a dense embed — an `##` inside an embed is larger than the
|
||||
* embed title above it. Bold keeps the hierarchy legible without competing.
|
||||
*/
|
||||
function headingsToBold(md) {
|
||||
return md.replace(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm, (_m, text) => `**${text.trim()}**`);
|
||||
}
|
||||
|
||||
/** Horizontal rules have no embed equivalent and read as stray dashes. */
|
||||
function stripRules(md) {
|
||||
return md.replace(/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/gm, "");
|
||||
}
|
||||
|
||||
/** Collapse the blank lines the earlier passes leave behind. */
|
||||
function tidy(md) {
|
||||
return md.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+$/gm, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate at a bullet boundary rather than mid-sentence.
|
||||
*
|
||||
* A hard slice at 4096 can cut inside a link or a bold run, which Discord then
|
||||
* renders as broken markup — worse than saying less.
|
||||
*/
|
||||
export function clampToLimit(text, limit = EMBED_DESCRIPTION_LIMIT, suffix = "\n\n…") {
|
||||
if (text.length <= limit) return text;
|
||||
const room = limit - suffix.length;
|
||||
const slice = text.slice(0, room);
|
||||
const cut = Math.max(slice.lastIndexOf("\n- "), slice.lastIndexOf("\n\n"));
|
||||
return (cut > room * 0.5 ? slice.slice(0, cut) : slice).trimEnd() + suffix;
|
||||
}
|
||||
|
||||
/** Full markdown → Discord-embed markdown. */
|
||||
export function toDiscordMarkdown(md) {
|
||||
return tidy(
|
||||
stripRules(
|
||||
headingsToBold(
|
||||
tablesToBullets(
|
||||
stripTemplateHeader(stripComments(stripDetails(String(md ?? "")))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the description for one release.
|
||||
*
|
||||
* `tailCount` is appended rather than dropped: a release whose highlights are
|
||||
* three bullets and whose tail is forty changes should not look like a quiet
|
||||
* week.
|
||||
*/
|
||||
export function buildDescription(body, { compareUrl } = {}) {
|
||||
const { head, tailCount } = splitBody(body);
|
||||
let out = toDiscordMarkdown(head);
|
||||
if (tailCount > 0) {
|
||||
const line = `*…plus ${tailCount} smaller change${tailCount === 1 ? "" : "s"}*`;
|
||||
out += `\n\n${compareUrl ? `[${line.slice(1, -1)}](${compareUrl})` : line}`;
|
||||
}
|
||||
return clampToLimit(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* One release → a Discord webhook payload.
|
||||
*
|
||||
* `allowed_mentions` is always explicit. A webhook with no restriction will
|
||||
* happily resolve an `@everyone` that happened to appear in release notes, and
|
||||
* the notes are not written with that in mind.
|
||||
*/
|
||||
export function buildPayload({
|
||||
version,
|
||||
body,
|
||||
releaseUrl,
|
||||
compareUrl,
|
||||
roleId: rawRoleId,
|
||||
username = WEBHOOK_USERNAME,
|
||||
avatarUrl = WEBHOOK_AVATAR_URL,
|
||||
channel = "staff",
|
||||
installHint = 'ComfyUI Manager → search "Wildcard Pipeline" → Update → restart ComfyUI',
|
||||
}) {
|
||||
const description = buildDescription(body, { compareUrl });
|
||||
// Normalised HERE rather than at the call site so every caller — CLI,
|
||||
// roll-up, future ones — gets the same guarantee.
|
||||
const roleId = normalizeRoleId(rawRoleId);
|
||||
return {
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
content: roleId ? `<@&${roleId}>` : "",
|
||||
allowed_mentions: { parse: [], roles: roleId ? [roleId] : [] },
|
||||
embeds: [
|
||||
{
|
||||
title: `Wildcard Pipeline ${version}`,
|
||||
url: releaseUrl || undefined,
|
||||
description,
|
||||
color: BRAND_COLOR,
|
||||
fields: reviewFields(channel),
|
||||
footer: { text: installHint },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff copy carries its own publish button; the public copy does not.
|
||||
*
|
||||
* This is what makes the staff channel a review step rather than a duplicate
|
||||
* feed: the post you are reading is the post that will go out, and the link to
|
||||
* send it is attached to it. Without that, "go and publish it" is a separate
|
||||
* instruction living in someone's head.
|
||||
*
|
||||
* Returned as `undefined` for the public channel rather than an empty array —
|
||||
* Discord rejects `fields: []` on some client versions, and an empty field
|
||||
* block would render as dead space in the announcement everyone sees.
|
||||
*/
|
||||
function reviewFields(channel) {
|
||||
if (channel !== "staff") return undefined;
|
||||
return [
|
||||
{
|
||||
name: "Ready to go public?",
|
||||
value:
|
||||
`[Run the workflow](${DISPATCH_URL}) with **channel: public** and ` +
|
||||
"**dry_run: false** to post this to the announcement channel.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Several releases → one catch-up payload.
|
||||
*
|
||||
* For the case that actually happens: announcements skipped for a few versions,
|
||||
* then one post covering the gap. Each release keeps its own heading so readers
|
||||
* can tell which version brought what, and the newest goes first because that is
|
||||
* the one people are deciding whether to install.
|
||||
*
|
||||
* `releases` is newest-first: `{ version, body }`.
|
||||
*/
|
||||
export function buildRollupPayload({
|
||||
releases, sinceVersion, releaseUrl, roleId, installHint, username, avatarUrl,
|
||||
channel = "staff",
|
||||
}) {
|
||||
const latest = releases[0];
|
||||
const sections = releases.map((r) => {
|
||||
const { head } = splitBody(r.body);
|
||||
const text = toDiscordMarkdown(head);
|
||||
return `**${r.version}**\n${text}`;
|
||||
});
|
||||
const joined = sections.join("\n\n");
|
||||
const base = buildPayload({
|
||||
version: latest?.version ?? "",
|
||||
body: "",
|
||||
releaseUrl,
|
||||
roleId,
|
||||
installHint,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
base.embeds[0].title = sinceVersion
|
||||
? `Wildcard Pipeline ${latest?.version ?? ""} — everything since ${sinceVersion}`
|
||||
: `Wildcard Pipeline ${latest?.version ?? ""}`;
|
||||
base.embeds[0].description = clampToLimit(joined);
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DISPATCH_URL,
|
||||
normalizeRoleId,
|
||||
splitBody,
|
||||
toDiscordMarkdown,
|
||||
buildDescription,
|
||||
buildPayload,
|
||||
buildRollupPayload,
|
||||
clampToLimit,
|
||||
EMBED_DESCRIPTION_LIMIT,
|
||||
} from "./discord-notes.mjs";
|
||||
|
||||
/**
|
||||
* The announcement is derived from notes that already exist, so the risk is not
|
||||
* "does it write good copy" — it is "does it mangle copy that was already
|
||||
* fine". Most of these pin a Discord-specific rendering gap.
|
||||
*/
|
||||
|
||||
const REAL_SHAPE = `## 🎉 Wildcard Pipeline v2.11.0
|
||||
|
||||
📖 [Docs (wiki)](https://x/wiki) · 💬 [Discord](https://discord.gg/x) · 📦 [Install](https://x/q) · 🐛 [Issues](https://x/i)
|
||||
|
||||
### ✨ What's new
|
||||
|
||||
A correctness fix for held modules, plus tools for reconnecting workflow items.
|
||||
|
||||
- **Held modules stay held.** A module set to **Hold across run** could re-roll.
|
||||
- **Search by id.** Pasting an 8-character id now finds the item.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Also in this release
|
||||
|
||||
- Wire collapse survives a live renderer switch
|
||||
- Modal keystrokes no longer escape
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.10.2…v2.11.0](https://x/compare)
|
||||
`;
|
||||
|
||||
describe("splitBody", () => {
|
||||
it("cuts at the modal marker and counts what is left behind", () => {
|
||||
const { head, tailCount } = splitBody(REAL_SHAPE);
|
||||
expect(head).toContain("Held modules stay held");
|
||||
expect(head).not.toContain("Also in this release");
|
||||
expect(tailCount).toBe(2);
|
||||
});
|
||||
|
||||
it("treats a body with no marker as all highlights", () => {
|
||||
const { head, tailCount } = splitBody("- one\n- two");
|
||||
expect(head).toBe("- one\n- two");
|
||||
expect(tailCount).toBe(0);
|
||||
});
|
||||
|
||||
it("survives an empty or missing body", () => {
|
||||
expect(splitBody("").head).toBe("");
|
||||
expect(splitBody(undefined).head).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDiscordMarkdown", () => {
|
||||
it("drops the GitHub template header — the Discord link is noise on Discord", () => {
|
||||
const out = toDiscordMarkdown(REAL_SHAPE);
|
||||
expect(out).not.toContain("🎉 Wildcard Pipeline");
|
||||
expect(out).not.toContain("discord.gg");
|
||||
expect(out).not.toContain("Docs (wiki)");
|
||||
});
|
||||
|
||||
it("keeps masked links, which DO render inside an embed", () => {
|
||||
expect(toDiscordMarkdown("see [the docs](https://x/d)")).toContain("[the docs](https://x/d)");
|
||||
});
|
||||
|
||||
it("turns headings into bold — an embed h2 outsizes the embed title", () => {
|
||||
const out = toDiscordMarkdown("### What's new\nbody");
|
||||
expect(out).toContain("**What's new**");
|
||||
expect(out).not.toMatch(/^#/m);
|
||||
});
|
||||
|
||||
it("converts tables to bullets, since Discord has none", () => {
|
||||
const out = toDiscordMarkdown("| Item | Result |\n| --- | --- |\n| #29 | shipped |");
|
||||
expect(out).toContain("- Item — Result");
|
||||
expect(out).toContain("- #29 — shipped");
|
||||
expect(out).not.toContain("|");
|
||||
});
|
||||
|
||||
it("strips <details>, which renders as raw tags", () => {
|
||||
const out = toDiscordMarkdown("keep\n<details><summary>x</summary>\n- hidden\n</details>");
|
||||
expect(out).toContain("keep");
|
||||
expect(out).not.toContain("hidden");
|
||||
expect(out).not.toContain("<details>");
|
||||
});
|
||||
|
||||
it("strips HTML comments and horizontal rules", () => {
|
||||
const out = toDiscordMarkdown("a\n\n<!-- note -->\n\n---\n\nb");
|
||||
expect(out).not.toContain("note");
|
||||
expect(out).not.toMatch(/^-{3,}$/m);
|
||||
expect(out).toContain("a");
|
||||
expect(out).toContain("b");
|
||||
});
|
||||
|
||||
it("leaves bold, italics and bullets alone", () => {
|
||||
const out = toDiscordMarkdown("- **bold** and *italic* and `code`");
|
||||
expect(out).toBe("- **bold** and *italic* and `code`");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDescription", () => {
|
||||
// A three-bullet highlights section over a forty-change release should not
|
||||
// read as a quiet week.
|
||||
it("says how much it is not showing", () => {
|
||||
const out = buildDescription(REAL_SHAPE);
|
||||
expect(out).toMatch(/plus 2 smaller changes/);
|
||||
});
|
||||
|
||||
it("links the tail to the compare view when there is one", () => {
|
||||
const out = buildDescription(REAL_SHAPE, { compareUrl: "https://x/compare" });
|
||||
expect(out).toContain("](https://x/compare)");
|
||||
});
|
||||
|
||||
// The "Also in this release" bullets live INSIDE a <details> block, and the
|
||||
// file ends with an authoring comment that contains bullets of its own.
|
||||
// Stripping details before counting reported a real 26-change release as 3.
|
||||
it("counts the bullets inside <details>, and ignores those in comments", () => {
|
||||
const body = [
|
||||
"- highlight",
|
||||
"<!-- /modal -->",
|
||||
"<details><summary>Also in this release — 3 smaller changes</summary>",
|
||||
"- one",
|
||||
"- two",
|
||||
"- three",
|
||||
"</details>",
|
||||
"<!--",
|
||||
" - not a change, this is authoring guidance",
|
||||
" - nor is this",
|
||||
"-->",
|
||||
].join("\n");
|
||||
expect(splitBody(body).tailCount).toBe(3);
|
||||
expect(buildDescription(body)).toMatch(/plus 3 smaller changes/);
|
||||
});
|
||||
|
||||
it("says nothing about a tail that does not exist", () => {
|
||||
expect(buildDescription("- only this")).not.toMatch(/smaller change/);
|
||||
});
|
||||
|
||||
it("uses the singular for one", () => {
|
||||
expect(buildDescription("- a\n<!-- /modal -->\n- b")).toMatch(/1 smaller change\b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampToLimit", () => {
|
||||
it("leaves anything under the cap untouched", () => {
|
||||
expect(clampToLimit("short")).toBe("short");
|
||||
});
|
||||
|
||||
// A hard slice can land inside a link or a bold run, which Discord then
|
||||
// renders as broken markup — worse than saying less.
|
||||
it("cuts at a bullet boundary, not mid-markup", () => {
|
||||
const body = Array.from({ length: 400 }, (_, i) => `- item ${i} with some text`).join("\n");
|
||||
const out = clampToLimit(body);
|
||||
expect(out.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
expect(out.split("\n").at(-3)).toMatch(/^- item \d+ with some text$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPayload", () => {
|
||||
it("puts the notes in an embed, because masked links need one", () => {
|
||||
const p = buildPayload({ version: "v2.12.0", body: REAL_SHAPE, releaseUrl: "https://x/r" });
|
||||
expect(p.embeds).toHaveLength(1);
|
||||
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0");
|
||||
expect(p.embeds[0].url).toBe("https://x/r");
|
||||
expect(p.embeds[0].footer.text).toMatch(/ComfyUI Manager/);
|
||||
});
|
||||
|
||||
// Release notes are not written with @everyone in mind, and a webhook will
|
||||
// happily resolve one that slipped in.
|
||||
it("never lets the notes mention anyone by accident", () => {
|
||||
const p = buildPayload({ version: "v1", body: "ping @everyone please" });
|
||||
expect(p.allowed_mentions.parse).toEqual([]);
|
||||
expect(p.allowed_mentions.roles).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* An unset secret was always safe. A MALFORMED one was not: any truthy string
|
||||
* went straight into `<@&…>`, so a stray space produced a literal `<@& >`
|
||||
* at the top of the most public message the project sends.
|
||||
*/
|
||||
describe("role ping", () => {
|
||||
const content = (roleId) => buildPayload({ version: "v1", body: "x", roleId }).content;
|
||||
|
||||
it("stays silent for every flavour of absent", () => {
|
||||
expect(content(undefined)).toBe("");
|
||||
expect(content(null)).toBe("");
|
||||
expect(content("")).toBe("");
|
||||
expect(content(" ")).toBe("");
|
||||
});
|
||||
|
||||
it("refuses anything that is not a snowflake, rather than guessing", () => {
|
||||
expect(content("not-an-id")).toBe("");
|
||||
expect(content("12345")).toBe(""); // too short
|
||||
expect(content("1234567890123456789012")).toBe(""); // too long
|
||||
expect(content("123abc456def78901")).toBe("");
|
||||
});
|
||||
|
||||
it("never emits a half-formed mention", () => {
|
||||
for (const bad of [" ", "not-an-id", "<@&>", "@everyone"]) {
|
||||
expect(content(bad)).not.toContain("<@&");
|
||||
}
|
||||
});
|
||||
|
||||
// "Copy ID" and copying the mention text are equally easy mistakes.
|
||||
it("accepts a pasted mention as well as a bare id", () => {
|
||||
expect(content("123456789012345678")).toBe("<@&123456789012345678>");
|
||||
expect(content("<@&123456789012345678>")).toBe("<@&123456789012345678>");
|
||||
expect(content(" 123456789012345678 ")).toBe("<@&123456789012345678>");
|
||||
});
|
||||
|
||||
it("keeps allowed_mentions in step with the content", () => {
|
||||
const bad = buildPayload({ version: "v1", body: "x", roleId: "junk" });
|
||||
expect(bad.allowed_mentions.roles).toEqual([]);
|
||||
const good = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
|
||||
expect(good.allowed_mentions.roles).toEqual(["123456789012345678"]);
|
||||
expect(good.allowed_mentions.parse).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizeRoleId is the single decision point", () => {
|
||||
expect(normalizeRoleId("<@&123456789012345678>")).toBe("123456789012345678");
|
||||
expect(normalizeRoleId("nope")).toBeUndefined();
|
||||
expect(normalizeRoleId(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("pings only the configured role, and only when configured", () => {
|
||||
expect(buildPayload({ version: "v1", body: "x" }).content).toBe("");
|
||||
const p = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
|
||||
expect(p.content).toBe("<@&123456789012345678>");
|
||||
expect(p.allowed_mentions.roles).toEqual(["123456789012345678"]);
|
||||
});
|
||||
|
||||
// A webhook posts under whatever name and avatar it was created with —
|
||||
// Discord's default is a generic name ("Captain Hook") and a placeholder
|
||||
// image, which is exactly what v2.12.0 went out as.
|
||||
it("posts under the project's own name and avatar, not the webhook's", () => {
|
||||
const p = buildPayload({ version: "v1", body: "x" });
|
||||
expect(p.username).toBe("Wildcard Pipeline");
|
||||
expect(p.avatar_url).toMatch(/^https:\/\/.+\.png$/);
|
||||
});
|
||||
|
||||
it("uses a raster avatar — Discord will not render an SVG", () => {
|
||||
expect(buildPayload({ version: "v1", body: "x" }).avatar_url).not.toMatch(/\.svg$/i);
|
||||
});
|
||||
|
||||
it("lets a caller override the identity", () => {
|
||||
const p = buildPayload({
|
||||
version: "v1", body: "x", username: "WP Staff", avatarUrl: "https://x/a.png",
|
||||
});
|
||||
expect(p.username).toBe("WP Staff");
|
||||
expect(p.avatar_url).toBe("https://x/a.png");
|
||||
});
|
||||
|
||||
/**
|
||||
* Two channels, deliberately asymmetric. The staff copy is the review step,
|
||||
* so it carries the button that publishes it onward; the public copy is the
|
||||
* announcement itself and must not tell readers how to re-send it.
|
||||
*/
|
||||
it("gives the staff copy a publish link, so review and button are one thing", () => {
|
||||
const p = buildPayload({ version: "v1", body: "x", channel: "staff" });
|
||||
const field = p.embeds[0].fields[0];
|
||||
expect(field.value).toContain(DISPATCH_URL);
|
||||
expect(field.value).toMatch(/channel.*public/i);
|
||||
});
|
||||
|
||||
it("defaults to the staff copy — the safe one to send by accident", () => {
|
||||
expect(buildPayload({ version: "v1", body: "x" }).embeds[0].fields).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves the public copy without the publish link", () => {
|
||||
const p = buildPayload({ version: "v1", body: "x", channel: "public" });
|
||||
// `undefined`, not `[]` — an empty field block renders as dead space.
|
||||
expect(p.embeds[0].fields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays inside the embed description cap", () => {
|
||||
const huge = Array.from({ length: 900 }, (_, i) => `- change ${i}`).join("\n");
|
||||
const p = buildPayload({ version: "v1", body: huge });
|
||||
expect(p.embeds[0].description.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRollupPayload", () => {
|
||||
// The case that actually happens: several versions skipped, then one post.
|
||||
it("labels each version so readers can tell what came from where", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [
|
||||
{ version: "v2.12.0", body: "- newest thing" },
|
||||
{ version: "v2.11.0", body: "- older thing" },
|
||||
],
|
||||
sinceVersion: "v2.10.2",
|
||||
releaseUrl: "https://x/r",
|
||||
});
|
||||
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0 — everything since v2.10.2");
|
||||
expect(p.embeds[0].description).toContain("**v2.12.0**");
|
||||
expect(p.embeds[0].description).toContain("**v2.11.0**");
|
||||
});
|
||||
|
||||
it("carries the channel through a roll-up", () => {
|
||||
const staff = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "staff",
|
||||
});
|
||||
const pub = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "public",
|
||||
});
|
||||
expect(staff.embeds[0].fields).toBeTruthy();
|
||||
expect(pub.embeds[0].fields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the identity on a roll-up too", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- x" }],
|
||||
sinceVersion: "v2.10.2",
|
||||
});
|
||||
expect(p.username).toBe("Wildcard Pipeline");
|
||||
expect(p.avatar_url).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
it("leads with the newest, which is what people decide to install", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [
|
||||
{ version: "v2.12.0", body: "- newest" },
|
||||
{ version: "v2.11.0", body: "- older" },
|
||||
],
|
||||
sinceVersion: "v2.10.2",
|
||||
});
|
||||
const d = p.embeds[0].description;
|
||||
expect(d.indexOf("**v2.12.0**")).toBeLessThan(d.indexOf("**v2.11.0**"));
|
||||
});
|
||||
|
||||
it("drops each release's own tail so a roll-up does not become a changelog", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- kept\n<!-- /modal -->\n- dropped" }],
|
||||
sinceVersion: "v2.11.0",
|
||||
});
|
||||
expect(p.embeds[0].description).toContain("kept");
|
||||
expect(p.embeds[0].description).not.toContain("dropped");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
* - web/ (built manager SPA)
|
||||
* - public/ (favicons + doc images referenced by the SPA)
|
||||
* - docs/help/ (per-node help markdown the canvas reads)
|
||||
* - locales/ (node display names + settings labels ComfyUI reads)
|
||||
* - README.md
|
||||
* - LICENSE
|
||||
*
|
||||
@@ -59,8 +60,55 @@ const SHIP_PATHS = [
|
||||
"web",
|
||||
"public",
|
||||
"docs/help",
|
||||
// ComfyUI reads `custom_nodes/<pack>/locales/<lang>/` for node display
|
||||
// names, descriptions and settings labels (app/custom_node_manager.py).
|
||||
// The registry archive picks this up automatically because it ships
|
||||
// everything git-tracked that `.comfyignore` doesn't exclude; this list is
|
||||
// hand-maintained, so it did not. The two artifacts silently disagreed —
|
||||
// see DEV_ONLY below for the guard that now stops that recurring.
|
||||
"locales",
|
||||
];
|
||||
|
||||
/**
|
||||
* Top-level tracked entries that deliberately do NOT ship.
|
||||
*
|
||||
* Exists to make the ship list total: every top-level path in git is either
|
||||
* shipped or listed here, and `assertShipListIsTotal` fails the release if a
|
||||
* new one appears in neither. Without it, adding a runtime directory and
|
||||
* forgetting this file produces a zip that installs and silently misbehaves —
|
||||
* which is exactly how `locales/` went missing.
|
||||
*/
|
||||
const DEV_ONLY = new Set([
|
||||
".comfyignore", ".gitattributes", ".github", ".gitignore", ".husky", ".npmrc",
|
||||
".releaserc.js", "CHANGELOG.md", "CLAUDE.md", "config", "conftest.py",
|
||||
"eslint.config.js", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",
|
||||
"scripts", "src", "tests", "tsconfig.json", "tsconfig.node.json",
|
||||
"vite.config.mts", "vitest.config.ts",
|
||||
]);
|
||||
|
||||
function assertShipListIsTotal() {
|
||||
const ls = spawnSync("git", ["ls-files"], { cwd: root, encoding: "utf8" });
|
||||
if (ls.status !== 0) {
|
||||
console.warn("pack-release: `git ls-files` unavailable — skipping ship-list completeness check");
|
||||
return;
|
||||
}
|
||||
// A ship path may name a subtree (`docs/help`); its top-level segment is
|
||||
// what makes the parent directory "accounted for".
|
||||
const covered = new Set(SHIP_PATHS.map((p) => p.split("/")[0]));
|
||||
const tracked = new Set(
|
||||
ls.stdout.split("\n").map((line) => line.trim().split("/")[0]).filter(Boolean),
|
||||
);
|
||||
const unaccounted = [...tracked].filter((e) => !covered.has(e) && !DEV_ONLY.has(e)).sort();
|
||||
if (unaccounted.length > 0) {
|
||||
console.error("pack-release: top-level paths in git that are neither shipped nor dev-only:");
|
||||
for (const p of unaccounted) console.error(` - ${p}`);
|
||||
console.error("Add each to SHIP_PATHS (ships to users) or DEV_ONLY (stays in the repo).");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
assertShipListIsTotal();
|
||||
|
||||
// Sanity: every path must exist before zipping. If `js/` or `web/`
|
||||
// is missing the release would ship a broken bundle — catch here so the
|
||||
// release workflow fails fast rather than uploading a half-baked zip.
|
||||
@@ -93,6 +141,13 @@ const args = [
|
||||
];
|
||||
|
||||
const result = spawnSync("zip", args, { stdio: "inherit", cwd: root });
|
||||
if (result.error?.code === "ENOENT") {
|
||||
// Windows has no `zip` by default, so a maintainer running this locally
|
||||
// otherwise gets a bare "exited with status null".
|
||||
console.error("pack-release: the `zip` CLI was not found on PATH.");
|
||||
console.error("The release workflow runs on ubuntu-latest where it is always present.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
console.error(`pack-release: zip exited with status ${result.status}`);
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Runtime contracts shared with the community web service.
|
||||
*
|
||||
* The community service is the schema authority — the sister project
|
||||
* (this manager) consumes these contracts when importing/installing
|
||||
* payloads downloaded from the community side. Keep these in sync
|
||||
* with `web/src/api/types.ts` in the Wildcard-Pipeline-Community repo.
|
||||
*
|
||||
* This module deliberately holds runtime/transport contracts only.
|
||||
* Manager-internal types (engine row shapes, UI state) live under
|
||||
* `src/manager/api/types.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One row from the community service's schema_catalog table (community
|
||||
* spec §3). Returned alongside a download payload so the installer can
|
||||
* plan its migration / tolerant-parse strategy without a second
|
||||
* round-trip. Mirrors the Pydantic schema in
|
||||
* `api/app/schemas/catalog.py` on the community side.
|
||||
*
|
||||
* Fields:
|
||||
* - `version`: schema version this row describes.
|
||||
* - `is_breaking_from_previous`: true when going from `version - 1`
|
||||
* to `version` introduced a NON-additive change (field removal,
|
||||
* type change, semantics change). Used by `decideInstallPath`'s
|
||||
* AND-fold check.
|
||||
* - `min_consumer_engine_version`: engine semver gate, or null when
|
||||
* no minimum is enforced.
|
||||
* - `notes`: free-form release notes for this schema bump.
|
||||
* - `created_at`: ISO timestamp the row was inserted, or null on
|
||||
* hand-seeded rows.
|
||||
*/
|
||||
export interface SchemaCatalogEntry {
|
||||
version: number;
|
||||
is_breaking_from_previous: boolean;
|
||||
min_consumer_engine_version: string | null;
|
||||
notes: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
@@ -183,6 +183,54 @@ describe("AssemblerHelper resolvedMap (#12 — boundary-disambiguation fix)", ()
|
||||
});
|
||||
});
|
||||
|
||||
describe("AssemblerHelper resolvedMap (SP2a — list-valued vars + .K accessor)", () => {
|
||||
it("joins a bare $mood (ListVar) and indexes $mood.0 / $mood.1", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
upstreamVars: ["mood"],
|
||||
templateVars: ["mood"],
|
||||
template: "$mood.0, $mood.1, $mood",
|
||||
resolvedMap: { mood: { items: ["sleepy", "serene", "dramatic"], sep: ", " } },
|
||||
},
|
||||
});
|
||||
const preview = wrapper.find('[data-test="asm-preview"]');
|
||||
const varSpans = preview.findAll("span.res");
|
||||
expect(varSpans.length).toBe(3);
|
||||
expect(varSpans[0].text()).toBe("sleepy"); // $mood.0
|
||||
expect(varSpans[1].text()).toBe("serene"); // $mood.1
|
||||
expect(varSpans[2].text()).toBe("sleepy, serene, dramatic"); // bare $mood → joined
|
||||
// No stranded `.0` / `.1` accessor leaked into the literal spans.
|
||||
expect(preview.text()).not.toContain(".0");
|
||||
expect(preview.text()).not.toContain(".1");
|
||||
});
|
||||
|
||||
it("renders an out-of-range $mood.5 as empty, not a leaked accessor", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
upstreamVars: ["mood"],
|
||||
templateVars: ["mood"],
|
||||
template: "[$mood.5]",
|
||||
resolvedMap: { mood: { items: ["a"], sep: ", " } },
|
||||
},
|
||||
});
|
||||
const preview = wrapper.find('[data-test="asm-preview"]');
|
||||
expect(preview.text()).toBe("[]");
|
||||
});
|
||||
|
||||
it("keeps an unresolved $name.K accessor visible in the chip text", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
upstreamVars: [],
|
||||
templateVars: ["ghost"],
|
||||
template: "$ghost.2",
|
||||
resolvedMap: {},
|
||||
},
|
||||
});
|
||||
const preview = wrapper.find('[data-test="asm-preview"]');
|
||||
expect(preview.text()).toContain("$ghost.2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AssemblerHelper missing-chip click (#20 — autoremove regression)", () => {
|
||||
it("clicking a missing chip calls onRemoveVar with the var name", async () => {
|
||||
let removed = "";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import ContextMenu, { type ContextMenuItem } from "../shared/ContextMenu.vue";
|
||||
import { kindIcon, type WpKind } from "../shared/kind-icons";
|
||||
import { applyVarAccessor, type ResolvedValue } from "../../widgets/richTokenize";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Flat array of upstream variable names. Primary prop shape. */
|
||||
@@ -15,7 +16,7 @@ const props = defineProps<{
|
||||
* fragile parallel-walk over the resolved string. When present, takes
|
||||
* precedence over `resolved`.
|
||||
*/
|
||||
resolvedMap?: Record<string, string>;
|
||||
resolvedMap?: Record<string, ResolvedValue>;
|
||||
/**
|
||||
* Pre-resolved string — template with $var replaced by resolved values.
|
||||
* Legacy fallback when `resolvedMap` not provided. Suffers from
|
||||
@@ -81,7 +82,10 @@ const upstreamNames = computed(() => props.upstreamVars ?? []);
|
||||
// doubled, it can't appear in real prompts.
|
||||
const ESCAPE_PLACEHOLDER = "��";
|
||||
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
// SP2a: capture an optional `.K` list accessor (group 2) so `$mood.0` consumes
|
||||
// the whole token — `m[0].length` then advances past `.K` (no stranded ".0"
|
||||
// literal) and group 2 drives applyVarAccessor in the substitution below.
|
||||
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/g;
|
||||
|
||||
/** Variable names referenced in the template. */
|
||||
const templateVarsInternal = computed(() => {
|
||||
@@ -142,11 +146,16 @@ const previewTokens = computed<PreviewToken[]>(() => {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
|
||||
const name = m[1];
|
||||
const idxStr = m[2];
|
||||
const index = idxStr != null ? parseInt(idxStr, 10) : undefined;
|
||||
const has = Object.prototype.hasOwnProperty.call(props.resolvedMap, name);
|
||||
// Unresolved var → keep `$name` so user sees what's missing.
|
||||
// Resolved → join (bare $name) or index (.K) via the shared accessor;
|
||||
// unresolved → keep the raw `$name.K` so the user sees what's missing.
|
||||
tokens.push({
|
||||
kind: "var",
|
||||
text: has ? props.resolvedMap[name] : `$${name}`,
|
||||
text: has
|
||||
? applyVarAccessor(props.resolvedMap[name], index)
|
||||
: `$${name}${idxStr != null ? "." + idxStr : ""}`,
|
||||
varName: name,
|
||||
});
|
||||
last = idx + m[0].length;
|
||||
@@ -165,7 +174,7 @@ const previewTokens = computed<PreviewToken[]>(() => {
|
||||
for (const m of props.template.matchAll(TEMPLATE_VAR_RE)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
|
||||
tokens.push({ kind: "var", text: `$${m[1]}`, varName: m[1] });
|
||||
tokens.push({ kind: "var", text: m[0], varName: m[1] });
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
if (last < props.template.length) tokens.push({ kind: "literal", text: props.template.slice(last) });
|
||||
|
||||
@@ -260,7 +260,7 @@ async function submit(mode: "update" | "new") {
|
||||
font-family: inherit;
|
||||
}
|
||||
.wp-stm-input:focus { outline: 1px solid var(--wp-accent-500, var(--wp-accent)); }
|
||||
.wp-stm-input--multi { resize: vertical; min-height: 46px; }
|
||||
.wp-stm-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 46px; }
|
||||
.wp-stm-select { cursor: pointer; }
|
||||
.wp-stm-preview {
|
||||
margin: 0;
|
||||
|
||||
@@ -174,7 +174,7 @@ function onCancel(): void {
|
||||
padding: 8px 10px;
|
||||
font-family: var(--wp-font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
resize: vertical; overscroll-behavior: contain;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ContextLoopWidget from "./ContextLoopWidget.vue";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { emptyContextLoopConfig } from "./types";
|
||||
import { currentFrame } from "./frame-cursor";
|
||||
import { deriveLoopSeeds } from "../shared/seed-derive";
|
||||
|
||||
describe("ContextLoopWidget", () => {
|
||||
it("renders defaults — hash chip active, both switches off, internal toggles on", () => {
|
||||
@@ -89,3 +92,215 @@ describe("ContextLoopWidget", () => {
|
||||
expect(w.find('[data-test="loop-iteration-internal"]').classes()).toContain("wp-loop__pi-btn--on");
|
||||
});
|
||||
});
|
||||
|
||||
function ws(extra: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: { ...emptyContextLoopConfig(), ...extra }, baseSeed: 42, count: 4 },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ContextLoopWidget seeds button", () => {
|
||||
it("renders the Per-iteration seeds button", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-btn"]').exists()).toBe(true);
|
||||
});
|
||||
it("badge shows the locked count when >0", () => {
|
||||
expect(ws({ seed_locks: { "1": 9, "2": 8 } }).find('[data-test="loop-seeds-badge"]').text()).toMatch(/2 locked/i);
|
||||
});
|
||||
it("hides the badge when nothing is locked", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("clicking opens the modal", async () => {
|
||||
const wr = ws();
|
||||
await wr.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
|
||||
});
|
||||
it("capitalizes the bypass row label", () => {
|
||||
expect(ws().text()).toContain("Bypass loop");
|
||||
});
|
||||
it("passes the override hint to the modal only when Override Context seed is off", async () => {
|
||||
const off = ws(); // override_seed false by default → nudge to turn it on
|
||||
await off.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
|
||||
|
||||
const on = ws({ override_seed: true });
|
||||
await on.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopWidget frame selector", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
function fw(count = 3) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: emptyContextLoopConfig(), count },
|
||||
attachTo: document.body, global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
it("renders base + one chip per iteration", () => {
|
||||
expect(fw(3).findAll('[data-test^="loop-frame-"]')).toHaveLength(4);
|
||||
});
|
||||
it("clicking a frame chip sets the shared cursor; base clears it", async () => {
|
||||
const wr = fw(3);
|
||||
await wr.find('[data-test="loop-frame-2"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(1);
|
||||
await wr.find('[data-test="loop-frame-base"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopWidget bypass (#13)", () => {
|
||||
it("shows an N-bypassed badge on the seeds button", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
|
||||
expect(w.find('[data-test="loop-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
|
||||
});
|
||||
it("marks bypassed edit-frame chips with a dashed-border class", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
|
||||
// frame index 1 -> chip #2 (data-test loop-frame-2)
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-4"]').classes()).toContain("wp-fchips__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-fchips__chip--bypassed");
|
||||
});
|
||||
|
||||
it("has no bypass badge when none are bypassed", () => {
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: emptyContextLoopConfig(), count: 5 } });
|
||||
expect(w.find('[data-test="loop-seeds-bypass-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("writes bypass changes from the modal back into the config", async () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [] };
|
||||
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 4 } });
|
||||
await w.get('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
w.findComponent(SeedListModal).vm.$emit("update:bypassFrames", [2]);
|
||||
const calls = w.emitted("update:modelValue")!;
|
||||
const last = calls[calls.length - 1][0] as typeof cfg;
|
||||
expect(last.bypass_frames).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Modifier-click on the frame chips. Both states were already visible here but
|
||||
* only changeable inside the modal, two clicks away and covering the node you
|
||||
* were reading.
|
||||
*/
|
||||
describe("ContextLoopWidget frame chip modifier-clicks", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
|
||||
function fw(cfgPatch: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}, count = 4) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: { ...emptyContextLoopConfig(), ...cfgPatch }, count, baseSeed: 42 },
|
||||
});
|
||||
}
|
||||
const lastEmit = (w: ReturnType<typeof fw>) => {
|
||||
const calls = w.emitted("update:modelValue");
|
||||
return calls?.[calls.length - 1]?.[0] as ReturnType<typeof emptyContextLoopConfig>;
|
||||
};
|
||||
|
||||
it("shift-click locks the frame to the seed it would have used", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
|
||||
const next = lastEmit(w);
|
||||
// Frame index 2, and the value must be a real derived seed — not a
|
||||
// placeholder, or the lock would silently change the output it froze.
|
||||
expect(Object.keys(next.seed_locks!)).toEqual(["2"]);
|
||||
expect(Number.isInteger(next.seed_locks!["2"])).toBe(true);
|
||||
});
|
||||
|
||||
it("shift-click on a locked frame unlocks it", async () => {
|
||||
const w = fw({ seed_locks: { "2": 777 } });
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
|
||||
expect(lastEmit(w).seed_locks).toEqual({});
|
||||
});
|
||||
|
||||
it("the chip-lock value matches what the modal's Lock all would write", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { shiftKey: true });
|
||||
const chipValue = lastEmit(w).seed_locks!["1"];
|
||||
expect(chipValue).toBe(deriveLoopSeeds(42, 4, "hash_index")[1]);
|
||||
});
|
||||
|
||||
it("alt-click bypasses a frame, and again re-enables it", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1]);
|
||||
|
||||
const w2 = fw({ bypass_frames: [1] });
|
||||
await w2.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w2).bypass_frames).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bypassed indices sorted so the config does not churn", async () => {
|
||||
const w = fw({ bypass_frames: [3] });
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("refuses to bypass the last running frame — a loop needs one", async () => {
|
||||
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
|
||||
await w.find('[data-test="loop-frame-4"]').trigger("click", { altKey: true });
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still lets the last running frame be re-enabled and locked", async () => {
|
||||
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
|
||||
await w.find('[data-test="loop-frame-1"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
// Moving the editor to another frame as a side effect of locking one is the
|
||||
// kind of surprise that makes people stop trusting a shortcut.
|
||||
it("a modifier click never also moves the edit cursor", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
|
||||
expect(currentFrame.value).toBe(null);
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
|
||||
it("a plain click still just selects the frame", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(2);
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("base takes no modifiers — it has no seed and nothing to bypass", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-base"]').trigger("click", { shiftKey: true });
|
||||
await w.find('[data-test="loop-frame-base"]').trigger("click", { altKey: true });
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks locked chips so the state is readable without the modal", () => {
|
||||
const w = fw({ seed_locks: { "1": 123 } });
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--locked");
|
||||
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-fchips__chip--locked");
|
||||
});
|
||||
|
||||
it("a frame can be both locked and bypassed", () => {
|
||||
const w = fw({ seed_locks: { "1": 123 }, bypass_frames: [1] });
|
||||
const chip = w.find('[data-test="loop-frame-2"]');
|
||||
expect(chip.classes()).toContain("wp-fchips__chip--locked");
|
||||
expect(chip.classes()).toContain("wp-fchips__chip--bypassed");
|
||||
});
|
||||
|
||||
it("ignores non-numeric lock keys rather than rendering a NaN state", () => {
|
||||
const w = fw({ seed_locks: { junk: 1, "1": 123 } as Record<string, number> });
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--locked");
|
||||
});
|
||||
|
||||
it("advertises the combos in the widget, not just in tooltips", () => {
|
||||
const w = fw();
|
||||
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/shift/i);
|
||||
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt|option/i);
|
||||
});
|
||||
|
||||
it("the tooltip names the action the next click will take", () => {
|
||||
const w = fw({ seed_locks: { "1": 123 }, bypass_frames: [1] });
|
||||
const t = w.find('[data-test="loop-frame-2"]').attributes("title")!;
|
||||
expect(t).toMatch(/unlock/i);
|
||||
expect(t).toMatch(/re-enable/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
* host glue (`src/widgets/context_loop.ts`) serializes to JSON and
|
||||
* pushes via `host.setValue` so ComfyUI's widget value matches.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import type { ContextLoopConfig, LoopStrategy } from "./types";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { deriveLoopSeeds } from "../shared/seed-derive";
|
||||
import { pushToast } from "../shared/toast-store";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
import FrameChips from "../shared/FrameChips.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,12 +26,24 @@ const props = withDefaults(
|
||||
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
|
||||
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
|
||||
nodeMode?: number;
|
||||
/** Base seed forwarded from the host ComfyUI INT widget. */
|
||||
baseSeed?: number;
|
||||
/** Iteration count forwarded from the host ComfyUI INT widget. */
|
||||
count?: number;
|
||||
/** The seed each frame used on the PREVIOUS run, captured from the node's
|
||||
* executed `loop_seeds` UI payload by the host glue. Drives the seed
|
||||
* modal's per-frame "lock previous" button. Null until a run lands. */
|
||||
previousSeeds?: number[] | null;
|
||||
}>(),
|
||||
{ nodeMode: 0 },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previousSeeds: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: ContextLoopConfig] }>();
|
||||
|
||||
/** Frame-grid collapse. Owned by the widget glue so it can persist to
|
||||
* `node.properties` and survive a workflow save. */
|
||||
const chipsCollapsed = defineModel<boolean>("chipsCollapsed", { default: false });
|
||||
|
||||
const STRATEGIES: { id: LoopStrategy; label: string; hint: string }[] = [
|
||||
{ id: "hash_index", label: "hash", hint: "Independent per-iteration; recommended for varied results." },
|
||||
{ id: "sequential", label: "sequential", hint: "base, base+1, base+2, … Predictable diffs." },
|
||||
@@ -46,6 +63,81 @@ const OVERRIDE_SEED_TOOLTIP =
|
||||
const isMuted = computed<boolean>(() => props.nodeMode === 2);
|
||||
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
|
||||
const seedsOpen = ref(false);
|
||||
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
|
||||
|
||||
function onSeedLocks(next: Record<string, number>): void {
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
}
|
||||
|
||||
const bypassedCount = computed(() => (props.modelValue.bypass_frames ?? []).length);
|
||||
/** 0-based bypassed frame indices — drives the dashed border on edit-frame
|
||||
* chips so bypassed frames read at a glance without opening the modal. */
|
||||
const bypassSet = computed(() => new Set(props.modelValue.bypass_frames ?? []));
|
||||
function onBypassFrames(next: number[]): void {
|
||||
emit("update:modelValue", { ...props.modelValue, bypass_frames: next });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Modifier-click on a frame chip — lock / bypass without the modal.
|
||||
*
|
||||
* Both states were already VISIBLE on the chips but only CHANGEABLE two clicks
|
||||
* away, inside a modal that covers the node you are reading. Toggling a couple
|
||||
* of frames is the common case; the modal earns its place for the bulk
|
||||
* operations (lock all, paste a series, out-of-range locks) and keeps them.
|
||||
*
|
||||
* Modifier choice: Alt and Shift both survive the trip. Ctrl is out because on
|
||||
* macOS Ctrl-click IS a right-click, so the combo would open a context menu
|
||||
* instead. Alt for bypass follows the seed modal's existing Alt-click-for-a-
|
||||
* variant idiom on its Copy button.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** 0-based locked frame indices. Keys are strings in the config; anything
|
||||
* non-numeric is ignored rather than becoming `NaN` in the set. */
|
||||
const lockSet = computed(() => {
|
||||
const out = new Set<number>();
|
||||
for (const k of Object.keys(props.modelValue.seed_locks ?? {})) {
|
||||
const n = Number(k);
|
||||
if (Number.isInteger(n)) out.add(n);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
/** The seed series this loop WOULD produce — the same values "Lock all" in
|
||||
* the modal writes, so a chip-lock and a modal-lock agree. */
|
||||
const derivedSeeds = computed(() =>
|
||||
deriveLoopSeeds(props.baseSeed, Math.max(1, props.count ?? 1), props.modelValue.strategy),
|
||||
);
|
||||
|
||||
/** In-range frames that are still running. Bypassing the last one would leave
|
||||
* the loop with nothing to execute, so the modal forbids it and so do we. */
|
||||
const activeFrameCount = computed(() => {
|
||||
let n = 0;
|
||||
for (let i = 0; i < Math.max(1, props.count ?? 1); i++) if (!bypassSet.value.has(i)) n++;
|
||||
return n;
|
||||
});
|
||||
|
||||
function toggleFrameLock(i: number): void {
|
||||
const next = { ...(props.modelValue.seed_locks ?? {}) };
|
||||
if (lockSet.value.has(i)) delete next[String(i)];
|
||||
else next[String(i)] = derivedSeeds.value[i] ?? props.baseSeed;
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
}
|
||||
|
||||
function toggleFrameBypass(i: number): void {
|
||||
const on = bypassSet.value.has(i);
|
||||
if (!on && activeFrameCount.value <= 1) {
|
||||
// Silence here would read as a broken click, and the reason is not
|
||||
// guessable from the chip.
|
||||
pushToast("A loop needs at least one running frame.", { severity: "warning" });
|
||||
return;
|
||||
}
|
||||
const next = new Set(bypassSet.value);
|
||||
if (on) next.delete(i);
|
||||
else next.add(i);
|
||||
onBypassFrames([...next].sort((a, b) => a - b));
|
||||
}
|
||||
|
||||
function pickStrategy(s: LoopStrategy): void {
|
||||
if (props.modelValue.strategy === s) return;
|
||||
emit("update:modelValue", { ...props.modelValue, strategy: s });
|
||||
@@ -108,6 +200,32 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="wp-loop__seedbtn" data-test="loop-seeds-btn" @click="seedsOpen = true">
|
||||
<span class="wp-loop__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
|
||||
Per-iteration seeds
|
||||
<span class="wp-loop__seedbtn-fill" />
|
||||
<span v-if="lockedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-badge">{{ lockedCount }} locked</span>
|
||||
<span v-if="bypassedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
|
||||
<svg class="wp-loop__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
|
||||
<!-- `base` is not a frame: it has no seed to lock and nothing to bypass,
|
||||
so it takes no modifiers. -->
|
||||
<FrameChips
|
||||
label="edit frame"
|
||||
:count="Math.max(1, count ?? 1)"
|
||||
:locked="[...lockSet]"
|
||||
:bypassed="[...bypassSet]"
|
||||
v-model:collapsed="chipsCollapsed"
|
||||
:current="currentFrame"
|
||||
selectable
|
||||
show-base
|
||||
bypass-interactive
|
||||
@select="setFrame"
|
||||
@toggle-lock="toggleFrameLock"
|
||||
@toggle-bypass="toggleFrameBypass"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="wp-loop__row"
|
||||
:title="OVERRIDE_SEED_TOOLTIP"
|
||||
@@ -169,7 +287,7 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
|
||||
<div class="wp-loop__row">
|
||||
<span class="wp-loop__row-label">bypass loop</span>
|
||||
<span class="wp-loop__row-label">Bypass loop</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-loop__switch"
|
||||
@@ -181,6 +299,13 @@ function toggleTotalInternal(): void {
|
||||
<span class="wp-loop__switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
|
||||
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
|
||||
:previous-seeds="previousSeeds"
|
||||
:bypass-frames="modelValue.bypass_frames ?? []"
|
||||
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
|
||||
@update:seed-locks="onSeedLocks" @update:bypass-frames="onBypassFrames" @close="seedsOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -204,9 +329,11 @@ function toggleTotalInternal(): void {
|
||||
}
|
||||
|
||||
.wp-loop__chips { display: flex; gap: 4px; }
|
||||
/* Strategy chips only — the per-frame grid now lives in FrameChips.vue. */
|
||||
.wp-loop__chip {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
background: var(--wp-bg-deep, var(--wp-bg, #0e1015));
|
||||
border: 1px solid var(--wp-border, #353841);
|
||||
border-radius: 3px;
|
||||
@@ -289,8 +416,8 @@ function toggleTotalInternal(): void {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 2px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
@@ -306,8 +433,15 @@ function toggleTotalInternal(): void {
|
||||
border-color: var(--wp-accent, #c4b5fd);
|
||||
color: var(--wp-accent, #c4b5fd);
|
||||
}
|
||||
.wp-loop__pi-btn .pi { font-size: 11px; }
|
||||
.wp-loop__pi-btn-label { font: 600 12px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
.wp-loop__pi-btn .pi { font-size: 9px; }
|
||||
.wp-loop__pi-btn-label { font: 600 10px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
|
||||
.wp-loop__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-loop__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.wp-loop__seedbtn-ico { color: var(--wp-accent); display: flex; }
|
||||
.wp-loop__seedbtn-fill { flex: 1; }
|
||||
.wp-loop__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
.wp-loop__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
|
||||
/* Bypass-on visually dims the strategy/override/var rows since the loop
|
||||
* is effectively off. The bypass row itself stays bright. */
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
|
||||
describe("frame-cursor", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
it("defaults to null (base)", () => { expect(currentFrame.value).toBe(null); });
|
||||
it("setFrame updates the shared ref", () => {
|
||||
setFrame(2);
|
||||
expect(currentFrame.value).toBe(2);
|
||||
setFrame(null);
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
// Module-scoped reactive singleton: the "current editing frame" cursor, shared
|
||||
// across every ContextWidget + ContextLoopWidget Vue app on the page. Each node
|
||||
// mounts its own app, so provide/inject can't span them — a shared module import
|
||||
// is the cross-app channel (same pattern as context/drag-store.ts). `null` = base
|
||||
// (no frame). Session-only — never serialized into the workflow.
|
||||
export const currentFrame = ref<number | null>(null);
|
||||
|
||||
export function setFrame(frame: number | null): void {
|
||||
currentFrame.value = frame;
|
||||
}
|
||||
@@ -30,6 +30,8 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: false,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -42,6 +44,8 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -65,8 +69,45 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
|
||||
expect(round).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopConfig seed_locks", () => {
|
||||
it("defaults seed_locks to {}", () => {
|
||||
expect(emptyContextLoopConfig().seed_locks).toEqual({});
|
||||
});
|
||||
it("parses seed_locks", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
|
||||
});
|
||||
it("drops non-numeric lock values", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
|
||||
});
|
||||
it("round-trips through serialize", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), seed_locks: { "0": 7 } };
|
||||
expect(parseContextLoopConfig(serializeContextLoopConfig(cfg)).seed_locks).toEqual({ "0": 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bypass_frames parse", () => {
|
||||
it("defaults to empty", () => {
|
||||
expect(emptyContextLoopConfig().bypass_frames).toEqual([]);
|
||||
expect(parseContextLoopConfig("{}").bypass_frames).toEqual([]);
|
||||
});
|
||||
it("dedups + sorts, keeps out-of-range, drops junk + negatives", () => {
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[3,1,3,0]}').bypass_frames).toEqual([0, 1, 3]);
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[2,-1,"x",2,999]}').bypass_frames).toEqual([2, 999]);
|
||||
});
|
||||
it("drops coercible non-numbers, not coerce them (Python parity)", () => {
|
||||
// true/"3"/null would coerce to 1/3/0 under Number() — must be dropped.
|
||||
expect(parseContextLoopConfig('{"bypass_frames":[true,"3",null,2]}').bypass_frames).toEqual([2]);
|
||||
});
|
||||
it("malformed collapses to empty", () => {
|
||||
expect(parseContextLoopConfig('{"bypass_frames":"nope"}').bypass_frames).toEqual([]);
|
||||
expect(parseContextLoopConfig("not json").bypass_frames).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface ContextLoopConfig {
|
||||
iteration_internal: boolean;
|
||||
/** Same idea for `$<iteration_var_name>_total`. */
|
||||
total_internal: boolean;
|
||||
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
|
||||
* indices re-derive from base+strategy. Empty by default. */
|
||||
seed_locks: Record<string, number>;
|
||||
/** 0-based iteration indices to bypass (skip generation + overrides).
|
||||
* Sorted, deduped, non-negative. Out-of-range (>= count) entries are
|
||||
* kept and re-apply if count grows. Empty by default. */
|
||||
bypass_frames: number[];
|
||||
}
|
||||
|
||||
const STRATEGIES = new Set<LoopStrategy>(["sequential", "hash_index", "prime_stride"]);
|
||||
@@ -32,6 +39,8 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +69,25 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
|
||||
if (typeof obj.bypass === "boolean") out.bypass = obj.bypass;
|
||||
if (typeof obj.iteration_internal === "boolean") out.iteration_internal = obj.iteration_internal;
|
||||
if (typeof obj.total_internal === "boolean") out.total_internal = obj.total_internal;
|
||||
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
|
||||
const locks: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
|
||||
}
|
||||
out.seed_locks = locks;
|
||||
}
|
||||
if (Array.isArray(obj.bypass_frames)) {
|
||||
const frames = new Set<number>();
|
||||
for (const x of obj.bypass_frames) {
|
||||
// Type-guard before accepting — do NOT coerce. Mirrors the Python
|
||||
// `_parse_config` (which rejects non-int via isinstance), so both
|
||||
// parsers degrade corrupt widget JSON identically (`true`, `"3"`,
|
||||
// `null` are dropped on both sides, not coerced to 1/3/0).
|
||||
if (typeof x !== "number" || !Number.isInteger(x) || x < 0) continue;
|
||||
frames.add(x);
|
||||
}
|
||||
out.bypass_frames = [...frames].sort((a, b) => a - b);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import ConfirmDialog from "../shared/ConfirmDialog.vue";
|
||||
import { api } from "../../manager/api/client";
|
||||
@@ -165,14 +165,15 @@ function onKeydown(ev: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
// Keys arrive from ModalShell (@keydown below) rather than window: the modal
|
||||
// shield stops inside-modal keys at the overlay so they cannot reach ComfyUI's
|
||||
// global shortcuts, so a window listener here would never fire.
|
||||
|
||||
const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="emit('close')">
|
||||
<ModalShell :visible="visible" @close="emit('close')" @keydown="onKeydown">
|
||||
<div class="wp-bp" role="dialog">
|
||||
<header class="wp-bp__head">
|
||||
<div class="wp-bp__head-text">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import ContextWidget from "./ContextWidget.vue";
|
||||
import { baseCodename } from "../../extension/node-codename";
|
||||
import { _resetForTests as resetDriftStore } from "./drift-store";
|
||||
import { currentFrame } from "../context-loop/frame-cursor";
|
||||
|
||||
beforeEach(() => {
|
||||
// Phase 5.5.4 — the picker now fetches the library on mount. Stub
|
||||
@@ -171,7 +173,7 @@ describe("ContextWidget drift dot", () => {
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
// Live hash differs from embedded → drift.
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-LIVE" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -206,6 +208,75 @@ describe("ContextWidget drift dot", () => {
|
||||
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("shows drift for an empty-hash module whose uuid IS in the live library", async () => {
|
||||
// Repro: a workflow whose embedded module carries payload_hash "" (a
|
||||
// malformed/legacy/builder-emitted copy) but whose uuid exists in the
|
||||
// library. It must NOT be treated as inline/verdict-less — surface it as
|
||||
// drift so the bulk "refresh drifted" button can relink it.
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { bbbbbbbb: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "bbbbbbbb", type: "wildcard", enabled: true,
|
||||
meta: { name: "wc", description: "", tags: [] }, entries: [],
|
||||
payload: { options: [] }, payload_hash: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 98, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find(".wp-mod-dot--drift").exists()).toBe(true);
|
||||
});
|
||||
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("leaves a genuinely inline module (empty hash, uuid NOT in library) verdict-less", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { somethingelse: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "cccccccc", type: "wildcard", enabled: true,
|
||||
meta: { name: "inline", description: "", tags: [] }, entries: [],
|
||||
payload: { options: [] }, payload_hash: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 97, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
// Give the poll a beat to land, then assert no drift/missing badges.
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".wp-mod-dot--drift").exists()).toBe(false);
|
||||
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget bulk refresh", () => {
|
||||
@@ -215,7 +286,7 @@ describe("ContextWidget bulk refresh", () => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-A", bbbbbbbb: "h-B" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-A" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-B" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -251,7 +322,7 @@ describe("ContextWidget bulk refresh", () => {
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
// Both embedded hashes differ from live → both drifted.
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A", bbbbbbbb: "live-B" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" }, bbbbbbbb: { type: "wildcard", payload_hash: "live-B" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -292,7 +363,7 @@ describe("ContextWidget bulk refresh", () => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" } } }), { status: 200 });
|
||||
}
|
||||
if (typeof url === "string" && url.endsWith("/embed-bundle")) {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { uuids: string[] };
|
||||
@@ -402,7 +473,7 @@ describe("ContextWidget bulk refresh", () => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -647,6 +718,105 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("Reset REFUSES (keeps children) when the instance isn't library-linked", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 })));
|
||||
const bundleUid = "buidEMPTY001";
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "dddddddd", type: "wildcard", enabled: true,
|
||||
meta: { name: "LocallyEdited" }, entries: [],
|
||||
payload: { var_binding: "v", options: [{ id: "o2", value: "y", weight: 2 }] },
|
||||
payload_hash: "h-edited", bundle_origin: bundleUid,
|
||||
},
|
||||
],
|
||||
bundles: [
|
||||
{
|
||||
_uid: bundleUid, library_id: "", start_idx: 0, end_idx: 0,
|
||||
enabled: true, collapsed: false, inserted_at_hash: "", name: "Unlinked", color: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 305, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
const header = document.querySelector(`[data-bundle-uid="${bundleUid}"][data-bundle-header]`);
|
||||
expect(header).toBeTruthy();
|
||||
header!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const reset = items.find((el) => el.textContent?.includes("Reset to library snapshot"));
|
||||
expect(reset).toBeTruthy();
|
||||
reset!.click();
|
||||
await flushPromises();
|
||||
|
||||
// Refused — the live child is untouched, no destructive empty commit.
|
||||
expect(wrapper.find('[data-module-id="dddddddd"]').exists()).toBe(true);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("Reset REFUSES (keeps children) when the library snapshot comes back empty", async () => {
|
||||
resetDriftStore();
|
||||
const fetchSpy = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (typeof url === "string" && url.match(/\/wp\/api\/bundles\/bn_empty$/) && (!init || init.method === "GET")) {
|
||||
return new Response(JSON.stringify({
|
||||
id: "bn_empty", name: "EmptySnap", description: "", color: null,
|
||||
category_id: null, tags: [], is_favorite: false,
|
||||
children: [], payload_hash: "ph-empty", version: 1, created_at: "", updated_at: "",
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const bundleUid = "buidEMPTY002";
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "dddddddd", type: "wildcard", enabled: true,
|
||||
meta: { name: "LocallyEdited" }, entries: [],
|
||||
payload: { var_binding: "v", options: [{ id: "o2", value: "y", weight: 2 }] },
|
||||
payload_hash: "h-edited", bundle_origin: bundleUid,
|
||||
},
|
||||
],
|
||||
bundles: [
|
||||
{
|
||||
_uid: bundleUid, library_id: "bn_empty", start_idx: 0, end_idx: 0,
|
||||
enabled: true, collapsed: false, inserted_at_hash: "ph-old", name: "EmptySnap", color: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 306, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
const header = document.querySelector(`[data-bundle-uid="${bundleUid}"][data-bundle-header]`);
|
||||
expect(header).toBeTruthy();
|
||||
header!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const reset = items.find((el) => el.textContent?.includes("Reset to library snapshot"));
|
||||
expect(reset).toBeTruthy();
|
||||
reset!.click();
|
||||
await flushPromises();
|
||||
|
||||
// The empty snapshot must NOT wipe the live child.
|
||||
expect(wrapper.find('[data-module-id="dddddddd"]').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("Reset re-adds children removed since insert (bundle end_idx grew via reconcile)", async () => {
|
||||
resetDriftStore();
|
||||
// Library has TWO children; in-graph bundle currently has ONE
|
||||
@@ -727,6 +897,124 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget Hold toggle via ctxmenu", () => {
|
||||
beforeEach(() => {
|
||||
// Hold is base-level; pin the shared frame cursor to base so the
|
||||
// menu item isn't disabled by a leftover edit-frame from another test.
|
||||
currentFrame.value = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
currentFrame.value = null;
|
||||
});
|
||||
|
||||
it("Hold across run stamps seed_scope=hold; reopening offers Release hold which flips back to vary", async () => {
|
||||
resetDriftStore();
|
||||
const onChange = vi.fn();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 410, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
// Un-held module → item reads "Hold across run" and is enabled.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
let items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item should appear for a wildcard row").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(false);
|
||||
hold!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterHold = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterHold).modules[0].instance.seed_scope).toBe("hold");
|
||||
|
||||
// Held module → label swaps to "Release hold"; clicking it varies again.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const release = items.find((el) => el.textContent?.includes("Release hold"));
|
||||
expect(release, "Held row should offer Release hold").toBeTruthy();
|
||||
release!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterRelease = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterRelease).modules[0].instance.seed_scope).toBe("vary");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("disables the Hold item when a loop edit-frame is active (hold is base-only)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 412, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
currentFrame.value = 0; // user is editing frame #0 of a loop
|
||||
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item still present while a frame is active").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("omits the Hold item for a constraint row (seed_scope not honored)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "aaaaaaaa", type: "wildcard", enabled: true, meta: { name: "Hair" }, entries: [], payload: { options: [], var_binding: "hair" } },
|
||||
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "Outfit" }, entries: [], payload: { options: [], var_binding: "outfit" } },
|
||||
{
|
||||
id: "cccccccc", type: "constraint", enabled: true,
|
||||
meta: { name: "Hair × Outfit" }, entries: [],
|
||||
payload: {
|
||||
source_wildcard_id: "aaaaaaaa", target_wildcard_id: "bbbbbbbb",
|
||||
matrix: { blonde: { casual: { mode: "allow", factor: 1 } } }, exceptions: [],
|
||||
}, payload_hash: "h",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 411, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-module-id="cccccccc"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items.find((el) => el.textContent?.includes("Hold across run"))).toBeUndefined();
|
||||
expect(items.find((el) => el.textContent?.includes("Release hold"))).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget summaryFor — constraint", () => {
|
||||
it("renders $src → $tgt · NxM with sibling lookup", async () => {
|
||||
resetDriftStore();
|
||||
@@ -946,6 +1234,64 @@ function mountWithModules(stubs: StubModule[]) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── D3a: library re-link (onPushSaved path) ──────────────────────────────────
|
||||
|
||||
describe("ContextWidget module re-link", () => {
|
||||
it("re-links a detached row + follows a sibling constraint's source_wildcard_id", async () => {
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 9100,
|
||||
initialJson: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "dead0001", type: "wildcard", enabled: true,
|
||||
meta: { name: "hair", description: "", tags: [] }, entries: [],
|
||||
payload: { options: [{ id: "o1", value: "red" }] }, payload_hash: "HASH_A",
|
||||
},
|
||||
{
|
||||
id: "c1", type: "constraint", enabled: true,
|
||||
meta: { name: "cn", description: "", tags: [] }, entries: [],
|
||||
payload: { source_wildcard_id: "dead0001", target_wildcard_id: "w2", matrix: {} },
|
||||
payload_hash: "HC",
|
||||
},
|
||||
],
|
||||
}),
|
||||
upstreamVars: [],
|
||||
onChange,
|
||||
},
|
||||
});
|
||||
// Let the initial parse settle (its suppressWatch cycle) so the relink
|
||||
// change below isn't coalesced into the swallowed first watcher tick.
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
// Two PushToLibraryModal instances exist: ModuleEditModal's (bound to
|
||||
// onPushToLibrarySaved, draft=null here) and ContextWidget's own (bound to
|
||||
// onPushSaved, the right-click path). The latter is last in DFS order.
|
||||
const ptls = wrapper.findAllComponents({ name: "PushToLibraryModal" });
|
||||
expect(ptls.length).toBeGreaterThan(0);
|
||||
const ptl = ptls[ptls.length - 1];
|
||||
ptl.vm.$emit("saved", {
|
||||
mode: "relink", id: "live0001", origId: "dead0001",
|
||||
payload_hash: "HASH_A", name: "hair", bundles_updated: [],
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const calls = onChange.mock.calls;
|
||||
const lastJson = calls[calls.length - 1]?.[0] as string;
|
||||
const parsed = JSON.parse(lastJson) as {
|
||||
modules: Array<{ id: string; type: string; payload: Record<string, unknown> }>;
|
||||
};
|
||||
const wc = parsed.modules.find((m) => m.type === "wildcard");
|
||||
const con = parsed.modules.find((m) => m.type === "constraint");
|
||||
expect(wc?.id).toBe("live0001");
|
||||
expect(con?.payload.source_wildcard_id).toBe("live0001");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sibling badge (Phase B: lives in summary line, not header) ────────────
|
||||
//
|
||||
// Phase A shipped the badge in the header always-visible. Phase B
|
||||
@@ -1120,7 +1466,7 @@ describe("ContextWidget status badges", () => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -1149,6 +1495,56 @@ describe("ContextWidget status badges", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("an UNLINKED bundle reads as missing, not modified", async () => {
|
||||
// A bundle with no `library_id` could never be MISSING (that check
|
||||
// early-returned false without one), so its only badge was MODIFIED —
|
||||
// diffing its children against a snapshot baseline from a library entry
|
||||
// that isn't there. "Modified against what?" had no answer.
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: {} }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
nodeId: 815,
|
||||
initialJson: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "child" }, entries: [], payload: {} },
|
||||
],
|
||||
bundles: [{
|
||||
_uid: "bu1",
|
||||
// No library_id — the unlinked shape.
|
||||
library_id: "",
|
||||
name: "Pose Library",
|
||||
start_idx: 0,
|
||||
end_idx: 0,
|
||||
enabled: true,
|
||||
// A stale baseline that would otherwise light up MOD.
|
||||
snapshot_fingerprint: "v2:deadbeef",
|
||||
}],
|
||||
}),
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find(".wp-mod-badge--missing").exists()).toBe(true);
|
||||
});
|
||||
// MOD is meaningless with no insert to have diverged from.
|
||||
expect(wrapper.find(".wp-mod-badge--modified").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("renders 'missing' text badge when uuid is not in library", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
@@ -1184,6 +1580,49 @@ describe("ContextWidget status badges", () => {
|
||||
});
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("renders 'clash' badge when the library row at this id is a different kind", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
// Same id, DIFFERENT kind in library (wildcard) than the embedded
|
||||
// constraint → type-conflict, not drift/missing.
|
||||
return new Response(
|
||||
JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live" } } }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
nodeId: 802,
|
||||
initialJson: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "aaaaaaaa", type: "constraint", enabled: true, meta: { name: "c" }, entries: [], payload: {}, payload_hash: "embedded" },
|
||||
],
|
||||
}),
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const badge = wrapper.find(".wp-mod-badge--clash");
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toBe("clash");
|
||||
});
|
||||
// type-conflict is mutually exclusive with drift + missing.
|
||||
expect(wrapper.find(".wp-mod-badge--drift").exists()).toBe(false);
|
||||
expect(wrapper.find(".wp-mod-badge--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ── B3: kind chip next to module name (mockup v5 lines 681, 696, 711, 722, 733) ─
|
||||
@@ -2104,3 +2543,164 @@ describe("ContextWidget bundle MOD detection", () => {
|
||||
bundleHashes.value = null; // restore
|
||||
});
|
||||
});
|
||||
|
||||
// ── Node codename header chip ───────────────────────────────────────────────
|
||||
// The node header shows a FIXED, read-only codename — a stable
|
||||
// adjective-noun derived from the litegraph node id (node-codename.ts) — so
|
||||
// cross-node UI (constraint reach pick-list, pair popovers) can name WHICH
|
||||
// node a target instance lives in. POV-independent + unique on the canvas,
|
||||
// and NOT editable (replaced the old editable label + walk-position letter).
|
||||
|
||||
describe("ContextWidget node codename chip", () => {
|
||||
it("renders [data-test=node-label] showing the node's stable codename", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 7000,
|
||||
initialJson: '{"version":1,"modules":[]}',
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
const chip = wrapper.find('[data-test="node-label"]');
|
||||
expect(chip.exists()).toBe(true);
|
||||
expect(chip.text()).toContain(baseCodename(7000));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("is read-only — exposes no edit input", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 7001,
|
||||
initialJson: '{"version":1,"modules":[]}',
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('[data-test="node-label-input"]').exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("derives the codename from the node id, ignoring chainModules / position", () => {
|
||||
// The core fix for the old A/B/C scheme: the chip is purely id-derived
|
||||
// now, so it never depends on walk position or any chainModules label.
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 7002,
|
||||
initialJson: '{"version":1,"modules":[]}',
|
||||
upstreamVars: [],
|
||||
chainModules: [
|
||||
{ id: "own", rowKey: "7002#own-uid", type: "wildcard", payload: {}, nodeLabel: "ignored-label" },
|
||||
],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
const chip = wrapper.find('[data-test="node-label"]');
|
||||
expect(chip.text()).toContain(baseCodename(7002));
|
||||
expect(chip.text()).not.toContain("ignored-label");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bulk toolbar: bundle-aware collapse / expand / toggle-all ────────────────
|
||||
// Toggle-all must operate on TOP-LEVEL units — standalone modules + bundle
|
||||
// MASTER toggles — never reach inside a bundle to flip child modules (the
|
||||
// master gates them; effective = master AND child). Collapse/expand-all must
|
||||
// flip bundle FRAMES too, not just module cards. The three buttons match the
|
||||
// small row-icon size.
|
||||
|
||||
describe("ContextWidget bulk toolbar — bundle-aware", () => {
|
||||
function mountMixed(opts: { bundleEnabled?: boolean; stdEnabled?: boolean } = {}) {
|
||||
const bundleEnabled = opts.bundleEnabled ?? true;
|
||||
const stdEnabled = opts.stdEnabled ?? true;
|
||||
const modules = [
|
||||
{
|
||||
id: "ch0aaaaa", _uid: "u0", type: "wildcard", enabled: true, collapsed: false,
|
||||
meta: { name: "ch0" }, entries: [], payload: { var_binding: "v0", options: [] },
|
||||
payload_hash: "ph0", bundle_origin: "bUID",
|
||||
},
|
||||
{
|
||||
id: "ch1aaaaa", _uid: "u1", type: "wildcard", enabled: true, collapsed: false,
|
||||
meta: { name: "ch1" }, entries: [], payload: { var_binding: "v1", options: [] },
|
||||
payload_hash: "ph1", bundle_origin: "bUID",
|
||||
},
|
||||
{
|
||||
id: "stdaaaaa", _uid: "u2", type: "wildcard", enabled: stdEnabled, collapsed: false,
|
||||
meta: { name: "std" }, entries: [], payload: { var_binding: "vs", options: [] },
|
||||
payload_hash: "phs",
|
||||
},
|
||||
];
|
||||
const bundles = [{
|
||||
_uid: "bUID", library_id: "lib_b", start_idx: 0, end_idx: 1,
|
||||
enabled: bundleEnabled, collapsed: false, name: "Group", color: null,
|
||||
}];
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
nodeId: 9400,
|
||||
initialJson: JSON.stringify({ version: 1, modules, bundles }),
|
||||
upstreamVars: [],
|
||||
onChange,
|
||||
},
|
||||
});
|
||||
return { wrapper, onChange };
|
||||
}
|
||||
function lastParsed(onChange: ReturnType<typeof vi.fn>) {
|
||||
const calls = onChange.mock.calls;
|
||||
return JSON.parse(calls[calls.length - 1][0] as string) as {
|
||||
modules: Array<{ _uid: string; enabled: boolean; collapsed?: boolean }>;
|
||||
bundles: Array<{ _uid: string; enabled: boolean; collapsed: boolean }>;
|
||||
};
|
||||
}
|
||||
const findMod = (p: ReturnType<typeof lastParsed>, uid: string) =>
|
||||
p.modules.find((m) => m._uid === uid)!;
|
||||
|
||||
it("toggle-all flips the bundle MASTER + standalone module, leaving bundle children untouched", async () => {
|
||||
const { wrapper, onChange } = mountMixed();
|
||||
onChange.mockClear();
|
||||
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
|
||||
const p = lastParsed(onChange);
|
||||
expect(p.bundles[0].enabled).toBe(false); // master flipped off
|
||||
expect(findMod(p, "u2").enabled).toBe(false); // standalone flipped off
|
||||
// Children NOT reached — gated by the master, restore-safe.
|
||||
expect(findMod(p, "u0").enabled).toBe(true);
|
||||
expect(findMod(p, "u1").enabled).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("toggle-all re-enables when every top-level unit is already off", async () => {
|
||||
const { wrapper, onChange } = mountMixed({ bundleEnabled: false, stdEnabled: false });
|
||||
onChange.mockClear();
|
||||
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
|
||||
const p = lastParsed(onChange);
|
||||
expect(p.bundles[0].enabled).toBe(true);
|
||||
expect(findMod(p, "u2").enabled).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("collapse-all collapses bundle FRAMES + module cards; expand-all expands both", async () => {
|
||||
const { wrapper, onChange } = mountMixed();
|
||||
onChange.mockClear();
|
||||
await wrapper.find('[aria-label="Collapse all modules"]').trigger("click");
|
||||
let p = lastParsed(onChange);
|
||||
expect(p.bundles[0].collapsed).toBe(true);
|
||||
expect(p.modules.every((m) => m.collapsed === true)).toBe(true);
|
||||
onChange.mockClear();
|
||||
await wrapper.find('[aria-label="Expand all modules"]').trigger("click");
|
||||
p = lastParsed(onChange);
|
||||
expect(p.bundles[0].collapsed).toBe(false);
|
||||
expect(p.modules.every((m) => m.collapsed === false)).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("the three bulk buttons use the small row-icon size class", () => {
|
||||
const { wrapper } = mountMixed();
|
||||
for (const label of ["Collapse all modules", "Expand all modules", "Toggle all enabled"]) {
|
||||
const btn = wrapper.find(`[aria-label="${label}"]`);
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.classes()).toContain("wp-btn--icon-sm");
|
||||
expect(btn.classes()).not.toContain("wp-btn--icon");
|
||||
}
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,24 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import ModuleEditModal from "./ModuleEditModal.vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import { _resetForTests } from "../../extension/preview-resolver";
|
||||
|
||||
// The v2 DerivationInstanceModal fetches /wp/api/modules on open to build
|
||||
// its `@{}` ref-data. Stub fetch so that round-trip settles harmlessly in
|
||||
// the derivation dispatcher tests below (shape mirrors ModulePickerModal).
|
||||
function stubEmptyModulesFetch(): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [], total: 0 }),
|
||||
})) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
// ModalShell uses <Teleport to="body">. VTU's `find` only walks the
|
||||
// component's own subtree, so disable teleport globally for these tests
|
||||
// — content lands inline where wrapper.find can reach it.
|
||||
@@ -523,7 +537,11 @@ describe("ModuleEditModal — combine v2 dispatcher", () => {
|
||||
// ── Derivation v2 dispatcher branch ────────────────────────────────────
|
||||
|
||||
describe("ModuleEditModal — derivation v2 dispatcher", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
beforeEach(() => {
|
||||
_resetForTests();
|
||||
stubEmptyModulesFetch();
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("renders DerivationInstanceModal (no tab strip) for derivation kind", async () => {
|
||||
const wrapper = mount(ModuleEditModal, {
|
||||
@@ -549,6 +567,47 @@ describe("ModuleEditModal — derivation v2 dispatcher", () => {
|
||||
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
|
||||
expect(saved.instance?.disabled_rule_ids).toEqual(["r1"]);
|
||||
});
|
||||
|
||||
// Bug parity: the shell must thread upstream + sibling vars into the
|
||||
// derivation modal so its action/condition override fields can offer
|
||||
// `$var` autocomplete — the SAME props the wildcard + combine modals
|
||||
// already receive. The `@{}` source is the LIBRARY (fetched inside the
|
||||
// modal), so sibling/chain MODULES are intentionally NOT forwarded.
|
||||
it("forwards upstream-vars + sibling-vars to DerivationInstanceModal", async () => {
|
||||
const wrapper = mount(ModuleEditModal, {
|
||||
...mountOpts,
|
||||
props: {
|
||||
visible: true,
|
||||
module: makeDerivation(),
|
||||
upstreamVars: ["age", "season"],
|
||||
siblingVars: ["mood"],
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
const dvm = wrapper.findComponent({ name: "DerivationInstanceModal" });
|
||||
expect(dvm.props("upstreamVars")).toEqual(["age", "season"]);
|
||||
expect(dvm.props("siblingVars")).toEqual(["mood"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Frame banner + revert-to-base (Phase E.1) ───────────────────────────
|
||||
|
||||
describe("ModuleEditModal — frame banner", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
|
||||
it("shows the frame banner + emits revert-frame when a frame is active", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: 1 } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(true);
|
||||
await w.find('[data-test="modal-frame-revert"]').trigger("click");
|
||||
expect(w.emitted("revert-frame")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("no frame banner on base (currentFrame null)", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: null } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Constraint v2 dispatcher branch ────────────────────────────────────
|
||||
@@ -583,6 +642,39 @@ describe("ModuleEditModal — constraint v2 dispatcher", () => {
|
||||
expect(saved.instance?.cell_mode_overrides).toEqual({ '["a","b"]': "exclude" });
|
||||
});
|
||||
|
||||
// Cross-node chain (SP3 P6 T16): the mount layer threads the flattened
|
||||
// chain as `chainModules`; the shell must forward it verbatim to the
|
||||
// constraint modal so a target wildcard in another Context node still
|
||||
// resolves. Resolution itself is covered in ConstraintInstanceModal.test.ts.
|
||||
it("forwards chainModules prop through to ConstraintInstanceModal", async () => {
|
||||
const chain = [
|
||||
{
|
||||
id: "bbbbbbbb",
|
||||
rowKey: "99#bbbbbbbb",
|
||||
type: "wildcard",
|
||||
displayName: "outfit",
|
||||
payload: { sub_categories: ["tee", "gown"], options: [] },
|
||||
},
|
||||
];
|
||||
const wrapper = mount(ModuleEditModal, {
|
||||
...mountOpts,
|
||||
props: { visible: true, module: makeConstraint(), chainModules: chain },
|
||||
});
|
||||
await nextTick();
|
||||
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
|
||||
expect(cnm.props("chainModules")).toEqual(chain);
|
||||
});
|
||||
|
||||
it("leaves the child chainModules undefined when the prop is omitted", async () => {
|
||||
const wrapper = mount(ModuleEditModal, {
|
||||
...mountOpts,
|
||||
props: { visible: true, module: makeConstraint() },
|
||||
});
|
||||
await nextTick();
|
||||
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
|
||||
expect(cnm.props("chainModules")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Reset overrides clears all override fields, preserves Identity", async () => {
|
||||
const m = {
|
||||
...makeConstraint(),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
|
||||
import { type ResolvedValue } from "../../widgets/richTokenize";
|
||||
import {
|
||||
INSTANCE_FIELDS_PER_KIND,
|
||||
type InstanceFieldKey,
|
||||
@@ -15,7 +17,8 @@ import FixedValuesInstanceModal from "./editors/fixed-values/FixedValuesInstance
|
||||
import CombineInstanceModal from "./editors/combine/CombineInstanceModal.vue";
|
||||
import DerivationInstanceModal from "./editors/derivation/DerivationInstanceModal.vue";
|
||||
import ConstraintInstanceModal from "./editors/constraint/ConstraintInstanceModal.vue";
|
||||
import type { PairingBadge } from "../../extension/constraint-pairs";
|
||||
import type { ChainModule, PairingBadge } from "../../extension/constraint-pairs";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -23,15 +26,23 @@ const props = defineProps<{
|
||||
module: ModuleEntry | null;
|
||||
/** Variable names defined upstream — used for autocomplete + validity checks. */
|
||||
upstreamVars?: string[];
|
||||
/** `$var` → its producing module + node. Forwarded to the per-kind modals so
|
||||
* var chips can name where a value comes from. */
|
||||
upstreamProducers?: Record<string, VarProducerLike>;
|
||||
/** Resolved upstream `$name → value` map. Combine modal uses this
|
||||
* to render a live preview pane with vars substituted. */
|
||||
upstreamResolved?: Record<string, string>;
|
||||
upstreamResolved?: Record<string, ResolvedValue>;
|
||||
/** Variable names defined by OTHER modules in the same node. */
|
||||
siblingVars?: string[];
|
||||
/** Other modules in the same WP_Context node — used by the constraint
|
||||
* preview to resolve source/target uuids back to var-binding names.
|
||||
* May include the module being edited; lookups by uuid skip self. */
|
||||
siblingModules?: ModuleEntry[];
|
||||
/** Flattened cross-node module chain (upstream + own + downstream
|
||||
* WP_Context nodes). Forwarded to the constraint modal so it can
|
||||
* resolve a source/target wildcard that lives in another Context
|
||||
* node, not just `siblingModules` (same-node only). */
|
||||
chainModules?: ChainModule[];
|
||||
/** Per-option pair badges for the currently-edited wildcard when it
|
||||
* acts as a constraint carrier (target reached via nested `@{uuid}`).
|
||||
* Keyed by option id. Empty for non-wildcard or non-carrier rows. */
|
||||
@@ -45,11 +56,15 @@ const props = defineProps<{
|
||||
* generic chain seed.
|
||||
*/
|
||||
lastUsedSeedReader?: (moduleId?: string) => number | null;
|
||||
/** When a frame override context is active, the 0-based frame index.
|
||||
* Null / undefined = editing the base (no banner shown). */
|
||||
currentFrame?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save", value: ModuleEntry): void;
|
||||
(e: "close"): void;
|
||||
(e: "revert-frame"): void;
|
||||
}>();
|
||||
|
||||
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
|
||||
@@ -57,18 +72,22 @@ const emit = defineEmits<{
|
||||
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
|
||||
const draft = ref<ModuleEntry | null>(null);
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v && props.module) {
|
||||
watch([() => props.visible, () => props.module], () => {
|
||||
if (props.visible && props.module) {
|
||||
// Re-clone on open AND whenever the source module changes under an open
|
||||
// modal — e.g. "Revert to base" clears the frame override, so editingModule
|
||||
// recomputes and the draft must resync to the base instance. (During normal
|
||||
// editing the draft is mutated locally; props.module identity is stable, so
|
||||
// this does not clobber in-progress edits.)
|
||||
draft.value = JSON.parse(JSON.stringify(props.module));
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
} else {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
draft.value = null;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
// Keys arrive from ModalShell, not from window: the modal shield stops
|
||||
// inside-modal keys at the overlay so they cannot reach ComfyUI's global
|
||||
// shortcuts, which means a window listener here would never fire.
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (!props.visible) return;
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
|
||||
@@ -141,7 +160,10 @@ function onConfirmDialogCancel(): void {
|
||||
* it's still on v1 and doesn't separate runtime from overrides.
|
||||
*/
|
||||
const RESET_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly InstanceFieldKey[]> = {
|
||||
wildcard: ["enabled_options", "option_weights", "category_filter"],
|
||||
wildcard: [
|
||||
"enabled_options", "option_weights", "category_filter", "exclude_null",
|
||||
"pick_min", "pick_max", "pick_separator",
|
||||
],
|
||||
fixed_values: ["values_overrides", "enabled_options"],
|
||||
combine: ["template_override", "variable_binding"],
|
||||
derivation: [
|
||||
@@ -230,7 +252,7 @@ function onSaveToLibraryClick(): void {
|
||||
}
|
||||
|
||||
interface PushSaveResult {
|
||||
mode: "update" | "fork" | "reattach";
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
id: string;
|
||||
payload_hash: string;
|
||||
bundles_updated: string[];
|
||||
@@ -244,12 +266,17 @@ function onPushToLibrarySaved(result: PushSaveResult): void {
|
||||
return;
|
||||
}
|
||||
setLibraryHash(result.id, result.payload_hash);
|
||||
if (result.mode === "fork" || result.mode === "reattach") {
|
||||
if (result.mode === "fork" || result.mode === "reattach" || result.mode === "relink") {
|
||||
// Fork: the new entry replaces the draft's identity; `_originalId`
|
||||
// lets saveEditedModule swap the row by old id during the next
|
||||
// save(). Reattach: same wiring — the dead uuid gets swapped for
|
||||
// the new one so MISSING clears.
|
||||
(draft.value as ModuleEntry & { _originalId?: string })._originalId = draft.value.id;
|
||||
// the new one so MISSING clears. Relink: point the detached row at an
|
||||
// EXISTING library uuid; `_relinkFrom` (the OLD id, captured before the
|
||||
// swap) additionally tells saveEditedModule to remap sibling @{} refs +
|
||||
// constraint source/target so cross-references follow the new uuid.
|
||||
const marked = draft.value as ModuleEntry & { _originalId?: string; _relinkFrom?: string };
|
||||
marked._originalId = draft.value.id;
|
||||
if (result.mode === "relink") marked._relinkFrom = draft.value.id;
|
||||
draft.value.id = result.id;
|
||||
draft.value.payload_hash = result.payload_hash;
|
||||
draft.value.meta = {
|
||||
@@ -260,7 +287,9 @@ function onPushToLibrarySaved(result: PushSaveResult): void {
|
||||
pushToast(
|
||||
result.mode === "reattach"
|
||||
? `Re-attached "${result.name}" to library`
|
||||
: `Saved as new library entry "${result.name}"`,
|
||||
: result.mode === "relink"
|
||||
? `Re-linked "${result.name}" to library`
|
||||
: `Saved as new library entry "${result.name}"`,
|
||||
{ severity: "success" },
|
||||
);
|
||||
save();
|
||||
@@ -372,7 +401,24 @@ function cancel() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="cancel">
|
||||
<ModalShell :visible="visible" @close="cancel" @keydown="onKeydown">
|
||||
<!-- Column so the frame banner stacks ABOVE the per-kind modal card
|
||||
(each per-kind modal renders a plain card; ModalShell's wrapper is
|
||||
a centered flex ROW, so a bare banner sibling would land beside the
|
||||
card). align-self:stretch on the banner spans it to the card width. -->
|
||||
<div class="wp-frame-edit-body">
|
||||
<!-- Frame-context banner — shown when the user is editing overrides
|
||||
for a specific iteration frame (#k). Sits above all per-kind
|
||||
modal bodies. Invisible on base editing (currentFrame == null). -->
|
||||
<div
|
||||
v-if="currentFrame != null"
|
||||
class="wp-modal-frame-banner"
|
||||
data-test="modal-frame-banner"
|
||||
>
|
||||
<span>Editing frame #{{ currentFrame + 1 }} — changes affect only this frame.</span>
|
||||
<button type="button" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</button>
|
||||
</div>
|
||||
|
||||
<!-- v2 wildcard branch — single-pane tailored modal renders its
|
||||
own header/footer/body. No v1 chrome (no .wp-medit wrapper,
|
||||
no tab strip, no LibraryRoundTripActions footer). -->
|
||||
@@ -381,8 +427,10 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="upstreamProducers"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -395,6 +443,7 @@ function cancel() {
|
||||
v-else-if="draft && draft.type === 'fixed_values'"
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -410,8 +459,10 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="upstreamProducers"
|
||||
:upstream-resolved="upstreamResolved"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -422,11 +473,19 @@ function cancel() {
|
||||
<!-- v2 derivation branch — single-pane tailored modal. Library
|
||||
(rule conditions / branches / actions) stays in SPA; modal
|
||||
exposes only display-name override + per-rule disable
|
||||
toggles via instance.disabled_rule_ids. -->
|
||||
toggles via instance.disabled_rule_ids. upstream/sibling vars
|
||||
feed the rule override fields' `$var` autocomplete; the `@{}`
|
||||
nested-ref source is the LIBRARY (fetched inside the modal), so
|
||||
sibling/chain MODULES are intentionally NOT forwarded here. -->
|
||||
<DerivationInstanceModal
|
||||
v-else-if="draft && draft.type === 'derivation'"
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="upstreamProducers"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -444,13 +503,15 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:sibling-modules="siblingModules"
|
||||
:chain-modules="chainModules"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@save-to-library="onSaveToLibraryClick"
|
||||
@clear-all-overrides="onClearAllOverrides"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
<!-- Themed confirm dialog — replaces window.confirm() for any
|
||||
@@ -487,5 +548,42 @@ function cancel() {
|
||||
* (WildcardInstanceModal, FixedValuesInstanceModal, etc.). The
|
||||
* `.wp-medit` chrome was the v1 tabbed shell — removed alongside
|
||||
* the v1 dispatch in the 2026-05-10 cleanup. */
|
||||
|
||||
.wp-frame-edit-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
.wp-modal-frame-banner {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 11px;
|
||||
border-radius: 6px;
|
||||
/* solid surface (amber mixed into the modal bg, not over the canvas) so the
|
||||
* text stays readable; amber = the override/warning accent used elsewhere. */
|
||||
background: color-mix(in oklab, var(--wp-amber) 14%, var(--wp-bg2));
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 38%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 500 11px var(--wp-font-sans);
|
||||
}
|
||||
.wp-modal-frame-banner button {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 55%, transparent);
|
||||
background: color-mix(in oklab, var(--wp-amber) 20%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 600 10px var(--wp-font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-modal-frame-banner button:hover {
|
||||
background: color-mix(in oklab, var(--wp-amber) 32%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -316,7 +316,14 @@
|
||||
:data-kind="byId.get(id)?.type"
|
||||
>
|
||||
<span class="wp-picker__sel-dot"></span>
|
||||
{{ byId.get(id)?.name ?? id }}
|
||||
<!-- Wrapped in its own span so it can ellipsis-clip. As a bare
|
||||
text node it wrapped onto a second line inside a chip fixed at
|
||||
22px tall, so a long name like "Accent piece (color-neutral)"
|
||||
spilled out of its own pill. -->
|
||||
<span
|
||||
class="wp-picker__sel-name"
|
||||
:title="byId.get(id)?.name ?? id"
|
||||
>{{ byId.get(id)?.name ?? id }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-picker__sel-close"
|
||||
@@ -1443,6 +1450,12 @@ onBeforeUnmount(detachCaptureListeners);
|
||||
font-weight: 500;
|
||||
max-width: 200px;
|
||||
}
|
||||
.wp-picker__sel-name {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wp-picker__sel-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { ref } from "vue";
|
||||
import ModuleRow from "./ModuleRow.vue";
|
||||
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import type { PairingBadge } from "../../extension/constraint-pairs";
|
||||
|
||||
/** Build a ModuleRowCtx whose pairing accessors return canned data and
|
||||
* whose ~30 helpers are inert stubs — ModuleRow only renders, so the
|
||||
* helpers just need to exist + be callable without throwing. */
|
||||
function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
|
||||
const noop = (): void => {};
|
||||
return {
|
||||
KIND_TITLE: { wildcard: "Wildcard", constraint: "Constraint" },
|
||||
kindIcon: () => "pi pi-box",
|
||||
kindChipModifier: (t: string) => t,
|
||||
varColorClass: () => "var-1",
|
||||
isCollapsed: () => true,
|
||||
isLocked: () => false,
|
||||
isInternal: () => false,
|
||||
isSeedLockable: () => false,
|
||||
isModified: () => false,
|
||||
isDrifted: () => false,
|
||||
isMissingFromLibrary: () => false,
|
||||
isTypeConflict: () => false,
|
||||
severityFor: () => null,
|
||||
conflictTooltip: () => "",
|
||||
conflictBadgeText: () => null,
|
||||
modifiedTooltip: () => "",
|
||||
summaryFor: () => "",
|
||||
summaryTokens: () => [],
|
||||
siblingInfo: () => null,
|
||||
rowGap: () => null,
|
||||
draggingModuleUid: ref<string | null>(null),
|
||||
recentDropUids: ref(new Set<string>()),
|
||||
pulseDelayFor: () => "0ms",
|
||||
toggleCollapsed: noop,
|
||||
toggleEnabled: noop,
|
||||
removeModule: noop,
|
||||
toggleLockOnCard: noop,
|
||||
toggleInternalOnCard: noop,
|
||||
onDragStart: noop,
|
||||
onDragEnd: noop,
|
||||
openContextMenu: noop,
|
||||
onCardKeydown: noop,
|
||||
pairingFor: () => null,
|
||||
viaInboundFor: () => [],
|
||||
contributorsFor: () => [],
|
||||
currentFrame: ref<number | null>(null),
|
||||
isHeld: () => false,
|
||||
isOverriddenOnFrame: () => false,
|
||||
effectiveLockedSeed: () => undefined,
|
||||
effectiveEnabled: (m) => m.enabled !== false,
|
||||
frameEnableOverride: () => null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function module(over: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "m1",
|
||||
_uid: "uid1",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "subject" },
|
||||
entries: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mountRow(ctx: ModuleRowCtx, mod: ModuleEntry) {
|
||||
return mount(ModuleRow, {
|
||||
props: { module: mod, idx: 0 },
|
||||
global: { provide: { [ModuleRowCtxKey as symbol]: ctx } },
|
||||
});
|
||||
}
|
||||
|
||||
const contrib = (n: number, colorIndex = n): PairingBadge => ({
|
||||
number: n,
|
||||
targetUuid: "aaaaaaaa",
|
||||
colorIndex,
|
||||
isOrphan: false,
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — badge before name", () => {
|
||||
it("renders the pair badge BEFORE the module name in DOM order", () => {
|
||||
const ctx = makeCtx({ contributorsFor: () => [contrib(1)] });
|
||||
const wrapper = mountRow(ctx, module());
|
||||
const html = wrapper.html();
|
||||
const badgeIdx = html.indexOf("wp-pair-badge");
|
||||
const nameIdx = html.indexOf("wp-module-name");
|
||||
expect(badgeIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(nameIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(badgeIdx).toBeLessThan(nameIdx);
|
||||
});
|
||||
|
||||
it("renders the sender badge before the name for a constraint row", () => {
|
||||
const senderBadge: PairingBadge = {
|
||||
number: 1,
|
||||
targetUuid: "aaaaaaaa",
|
||||
colorIndex: 2,
|
||||
isOrphan: false,
|
||||
reach: { mode: "all" },
|
||||
};
|
||||
const ctx = makeCtx({ pairingFor: () => senderBadge });
|
||||
const wrapper = mountRow(ctx, module({ type: "constraint", meta: { name: "no red" } }));
|
||||
const html = wrapper.html();
|
||||
expect(html.indexOf("wp-pair-badge")).toBeLessThan(html.indexOf("wp-module-name"));
|
||||
// Sender carries its reach suffix.
|
||||
expect(wrapper.text()).toContain("·all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — contributor cluster", () => {
|
||||
it("renders one chip per contributor when there are 2 or fewer", () => {
|
||||
const ctx = makeCtx({ contributorsFor: () => [contrib(1), contrib(2)] });
|
||||
const wrapper = mountRow(ctx, module());
|
||||
expect(wrapper.findAll(".wp-pair-badge")).toHaveLength(2);
|
||||
expect(wrapper.find(".wp-pair-badge--collapse").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders exactly ONE collapse chip ↥×N when there are 3 or more contributors", () => {
|
||||
const ctx = makeCtx({ contributorsFor: () => [contrib(1), contrib(2), contrib(3)] });
|
||||
const wrapper = mountRow(ctx, module());
|
||||
const collapse = wrapper.findAll(".wp-pair-badge--collapse");
|
||||
expect(collapse).toHaveLength(1);
|
||||
expect(collapse[0].text()).toContain("↥×3");
|
||||
// The collapse chip REPLACES the individual chips — no per-contributor
|
||||
// direct chips alongside it.
|
||||
expect(wrapper.findAll(".wp-pair-badge")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders no contributor chip on a row no constraint covers", () => {
|
||||
const ctx = makeCtx({ contributorsFor: () => [] });
|
||||
const wrapper = mountRow(ctx, module());
|
||||
expect(wrapper.find(".wp-pair-badge").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — iteration-aware badges", () => {
|
||||
it("shows a held badge when seed_scope is hold", () => {
|
||||
const mod = module({ instance: { seed_scope: "hold" } as ModuleEntry["instance"] });
|
||||
const ctx = makeCtx({ isHeld: (m) => m.instance?.seed_scope === "hold" });
|
||||
const wrapper = mountRow(ctx, mod);
|
||||
expect(wrapper.find('[data-test="mod-held"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an override badge labelled with the current frame", () => {
|
||||
const frame = ref<number | null>(1);
|
||||
const mod = module({ iteration_overrides: { "1": { weight: 2 } } as ModuleEntry["iteration_overrides"] });
|
||||
const ctx = makeCtx({
|
||||
currentFrame: frame,
|
||||
isOverriddenOnFrame: (m) => {
|
||||
const k = frame.value;
|
||||
return k != null && !!m.iteration_overrides?.[String(k)];
|
||||
},
|
||||
});
|
||||
const wrapper = mountRow(ctx, mod);
|
||||
const badge = wrapper.find('[data-test="mod-override"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — per-frame enable/disable", () => {
|
||||
it("checkbox is unchecked when effectiveEnabled is false on the active frame", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => false });
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect(checkbox.exists()).toBe(true);
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("checkbox is checked when effectiveEnabled is true (base-off but on for this frame)", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => true });
|
||||
const wrapper = mountRow(ctx, module({ enabled: false }));
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an off-#k badge when frameEnableOverride is 'off'", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "off" });
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
const badge = wrapper.find('[data-test="mod-frame-disabled"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#3"); // frame index 2 (0-based) → "#3"
|
||||
});
|
||||
|
||||
it("shows an on-#k badge when frameEnableOverride is 'on'", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "on" });
|
||||
const wrapper = mountRow(ctx, module({ enabled: false }));
|
||||
const badge = wrapper.find('[data-test="mod-frame-enabled"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#3");
|
||||
});
|
||||
|
||||
it("checkbox change calls ctx.toggleEnabled with the row index", async () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const calls: number[] = [];
|
||||
const ctx = makeCtx({
|
||||
currentFrame: frame,
|
||||
toggleEnabled: (idx) => { calls.push(idx); },
|
||||
});
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
await wrapper.find('input[type="checkbox"]').trigger("change");
|
||||
expect(calls).toEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ if (!ctx) throw new Error("ModuleRow: missing moduleRowCtx provider");
|
||||
const {
|
||||
KIND_TITLE, kindIcon, kindChipModifier, varColorClass,
|
||||
isCollapsed, isLocked, isInternal, isSeedLockable,
|
||||
isModified, isDrifted, isMissingFromLibrary,
|
||||
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
|
||||
severityFor, conflictTooltip, conflictBadgeText,
|
||||
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
|
||||
rowGap,
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
|
||||
pairingFor,
|
||||
viaInboundFor,
|
||||
contributorsFor,
|
||||
} = ctx;
|
||||
</script>
|
||||
|
||||
@@ -35,7 +36,7 @@ const {
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
:class="{
|
||||
'wp-disabled': !module.enabled,
|
||||
'wp-disabled': !(ctx?.effectiveEnabled(module) ?? (module.enabled !== false)),
|
||||
'wp-conflict-error': severityFor(module._uid ?? module.id) === 'error',
|
||||
'wp-conflict-warning': severityFor(module._uid ?? module.id) === 'warning',
|
||||
'wp-conflict-info': severityFor(module._uid ?? module.id) === 'info',
|
||||
@@ -73,8 +74,12 @@ const {
|
||||
:title="isCollapsed(module) ? 'Expand' : 'Collapse'" @click="toggleCollapsed(idx)">
|
||||
<i :class="['pi', isCollapsed(module) ? 'pi-caret-right' : 'pi-caret-down']" aria-hidden="true"></i>
|
||||
</button>
|
||||
<label class="wp-toggle" draggable="false" :title="module.enabled ? 'Disable' : 'Enable'">
|
||||
<input type="checkbox" :checked="module.enabled" :aria-label="`enable ${module.meta.name}`" @change="toggleEnabled(idx)" />
|
||||
<label class="wp-toggle" draggable="false"
|
||||
:title="ctx && ctx.currentFrame.value != null ? (ctx.effectiveEnabled(module) ? 'Disable on this frame' : 'Enable on this frame') : (module.enabled ? 'Disable' : 'Enable')">
|
||||
<input type="checkbox"
|
||||
:checked="ctx ? ctx.effectiveEnabled(module) : module.enabled"
|
||||
:aria-label="`enable ${module.meta.name}`"
|
||||
@change="toggleEnabled(idx)" />
|
||||
<span class="wp-toggle-mark"></span>
|
||||
</label>
|
||||
<span class="wp-row-type-icon" :title="module.type" aria-hidden="true">
|
||||
@@ -84,11 +89,36 @@ const {
|
||||
class="wp-kind-chip" :class="`wp-kind-chip--${kindChipModifier(module.type)}`">
|
||||
{{ KIND_TITLE[module.type] ?? module.type }}
|
||||
</span>
|
||||
<!-- Constraint (SENDER) row: the constraint's own `#N` chip,
|
||||
carrying its reach selector (`·all` / `·first` / `·n2` /
|
||||
`·pick`) + the ↪ via-glyph when its target is reached through
|
||||
a nested ref. One chip per constraint row. -->
|
||||
<PairBadge
|
||||
v-if="pairingFor(module._uid ?? module.id)"
|
||||
v-if="module.type === 'constraint' && pairingFor(module._uid ?? module.id)"
|
||||
:pair="pairingFor(module._uid ?? module.id)!"
|
||||
:variant="module.type === 'constraint' ? 'sender' : 'direct'"
|
||||
variant="sender"
|
||||
/>
|
||||
<!-- Target (WILDCARD) row: the CONTRIBUTOR CLUSTER — every
|
||||
constraint whose reach covers this instance. ≤2 contributors
|
||||
render as individual `#N` chips; ≥3 collapse into ONE solid
|
||||
`↥×N` chip (popover lists them all) so a heavily-constrained
|
||||
row never sprawls into 3+ chips. -->
|
||||
<template v-else-if="module.type !== 'constraint' && contributorsFor(module._uid ?? module.id).length > 0">
|
||||
<template v-if="contributorsFor(module._uid ?? module.id).length <= 2">
|
||||
<PairBadge
|
||||
v-for="(c, ci) in contributorsFor(module._uid ?? module.id)"
|
||||
:key="`contrib-${c.number}-${c.targetUuid}-${ci}`"
|
||||
:pair="c"
|
||||
variant="direct"
|
||||
/>
|
||||
</template>
|
||||
<PairBadge
|
||||
v-else
|
||||
:pairs="contributorsFor(module._uid ?? module.id)"
|
||||
:count="contributorsFor(module._uid ?? module.id).length"
|
||||
variant="collapse"
|
||||
/>
|
||||
</template>
|
||||
<!-- Carrier-side: ONE collapsed `↪×N` chip aggregating every
|
||||
constraint whose target lives in one of THIS wildcard's
|
||||
nested @{} refs. Solid background; multi-color fallback when
|
||||
@@ -109,6 +139,10 @@ const {
|
||||
title="Drifted — library has a newer version. Right-click → Refresh from library." aria-hidden="true"></span>
|
||||
<span v-if="isDrifted(module)" class="wp-mod-badge wp-mod-badge--drift"
|
||||
title="Drifted — library has a newer version. Right-click → Refresh from library.">drift</span>
|
||||
<span v-if="isTypeConflict(module)" class="wp-mod-dot wp-mod-dot--clash"
|
||||
title="Id clash — a different kind of item uses this id in your library. Right-click → Push to library to add this one as a new entry." aria-hidden="true"></span>
|
||||
<span v-if="isTypeConflict(module)" class="wp-mod-badge wp-mod-badge--clash"
|
||||
title="Id clash — a different kind of item uses this id in your library. Right-click → Push to library to add this one as a new entry.">clash</span>
|
||||
<span v-if="isMissingFromLibrary(module)" class="wp-mod-dot wp-mod-dot--missing"
|
||||
title="Not in library — right-click → Save to library to add it" aria-hidden="true"></span>
|
||||
<span v-if="isMissingFromLibrary(module)" class="wp-mod-badge wp-mod-badge--missing"
|
||||
@@ -119,11 +153,15 @@ const {
|
||||
class="wp-conflict-badge"
|
||||
:class="`wp-conflict-badge--${severityFor(module._uid ?? module.id)}`"
|
||||
:title="conflictTooltip(module._uid ?? module.id)">{{ conflictBadgeText(module._uid ?? module.id) }}</span>
|
||||
<span v-if="ctx?.isHeld(module)" class="wp-mod-badge wp-mod-badge--clash" data-test="mod-held" title="Held across the run">held</span>
|
||||
<span v-if="ctx?.isOverriddenOnFrame(module)" class="wp-conflict-badge wp-conflict-badge--warning" data-test="mod-override" title="Overridden on this frame">override #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
<span v-if="ctx?.frameEnableOverride(module) === 'off'" class="wp-mod-badge wp-mod-badge--missing" data-test="mod-frame-disabled" title="Disabled on this frame">off #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
<span v-else-if="ctx?.frameEnableOverride(module) === 'on'" class="wp-mod-badge wp-mod-badge--on" data-test="mod-frame-enabled" title="Enabled on this frame">on #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
</span>
|
||||
<div class="wp-mod-actions" draggable="false">
|
||||
<button v-if="isSeedLockable(module)" type="button" class="wp-btn--icon-sm wp-btn--warn"
|
||||
:class="{ 'is-locked': isLocked(module) }" data-test="row-action-lock"
|
||||
:title="isLocked(module) ? `Locked seed: ${module.instance?.locked_seed}. Click to unlock.` : 'Lock seed'"
|
||||
:title="isLocked(module) ? `Locked seed: ${ctx?.effectiveLockedSeed(module)}. Click to unlock.` : 'Lock seed'"
|
||||
:aria-label="isLocked(module) ? 'Unlock seed' : 'Lock seed'"
|
||||
@click.stop="toggleLockOnCard(idx)"><i class="pi pi-lock" /></button>
|
||||
<button v-if="module.type === 'wildcard' || module.type === 'fixed_values' || module.type === 'combine' || module.type === 'derivation'"
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import PairBadge from "./PairBadge.vue";
|
||||
import type { PairingBadge } from "../../extension/constraint-pairs";
|
||||
|
||||
afterEach(() => {
|
||||
// Popover teleports to <body> — strip leftover overlays between tests.
|
||||
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
|
||||
});
|
||||
|
||||
/** Minimal sender badge with a given reach selector. */
|
||||
function senderPair(reach: PairingBadge["reach"], over: Partial<PairingBadge> = {}): PairingBadge {
|
||||
return {
|
||||
number: 1,
|
||||
targetUuid: "aaaaaaaa",
|
||||
colorIndex: 3,
|
||||
isOrphan: false,
|
||||
reach,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PairBadge.vue — sender reach suffix", () => {
|
||||
it("renders ·all for the default all reach", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: senderPair({ mode: "all" }), variant: "sender" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("#1");
|
||||
expect(wrapper.text()).toContain("·all");
|
||||
});
|
||||
|
||||
it("renders ·first for first reach", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: senderPair({ mode: "first" }), variant: "sender" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("·first");
|
||||
});
|
||||
|
||||
it("renders ·n{count} for next reach", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: senderPair({ mode: "next", count: 2 }), variant: "sender" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("·n2");
|
||||
});
|
||||
|
||||
it("renders ·pick for pick reach", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: senderPair({ mode: "pick", picks: [] }), variant: "sender" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("·pick");
|
||||
});
|
||||
|
||||
it("keeps the via ↪ glyph alongside the reach suffix", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: {
|
||||
pair: senderPair(
|
||||
{ mode: "all" },
|
||||
{ via: { carrierRowKey: "c1", carrierName: "backdrop", optionIds: ["o1"], routeChain: ["aaaaaaaa"] } },
|
||||
),
|
||||
variant: "sender",
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain("↪");
|
||||
expect(wrapper.text()).toContain("·all");
|
||||
});
|
||||
|
||||
it("does not append a reach suffix to a direct (target) chip", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pair: { number: 1, targetUuid: "aaaaaaaa", colorIndex: 3, isOrphan: false }, variant: "direct" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("#1");
|
||||
expect(wrapper.text()).not.toContain("·");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PairBadge.vue — collapse variant", () => {
|
||||
const pairs: PairingBadge[] = [
|
||||
{ number: 1, targetUuid: "aaaaaaaa", colorIndex: 1, isOrphan: false },
|
||||
{ number: 2, targetUuid: "aaaaaaaa", colorIndex: 2, isOrphan: false },
|
||||
{ number: 3, targetUuid: "aaaaaaaa", colorIndex: 3, isOrphan: false },
|
||||
];
|
||||
|
||||
it("renders ↥×N from the pairs length", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pairs, variant: "collapse" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("↥×3");
|
||||
});
|
||||
|
||||
it("renders ↥×N from an explicit count prop", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { count: 4, pairs, variant: "collapse" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("↥×4");
|
||||
});
|
||||
|
||||
it("carries the .wp-pair-badge--collapse class and not the carrier multi-color class", () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
props: { pairs, variant: "collapse" },
|
||||
});
|
||||
const chip = wrapper.find(".wp-pair-badge");
|
||||
expect(chip.exists()).toBe(true);
|
||||
expect(chip.classes()).toContain("wp-pair-badge--collapse");
|
||||
// Solid collapse chip is NOT the dim multi-color carrier surface.
|
||||
expect(chip.classes()).not.toContain("wp-pair-badge--via-multi");
|
||||
});
|
||||
|
||||
it("opens a popover listing every collapsed pair", async () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
attachTo: document.body,
|
||||
props: { pairs, variant: "collapse" },
|
||||
});
|
||||
await wrapper.find(".wp-pair-badge").trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
const pop = document.querySelector(".wp-pair-pop");
|
||||
expect(pop).not.toBeNull();
|
||||
// Each numbered entry should be present.
|
||||
expect(pop?.textContent).toContain("#1");
|
||||
expect(pop?.textContent).toContain("#2");
|
||||
expect(pop?.textContent).toContain("#3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PairBadge.vue — popover REACH + PATH rows", () => {
|
||||
it("renders a REACH row from the primary pair's reach", async () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
attachTo: document.body,
|
||||
props: { pair: senderPair({ mode: "next", count: 2 }), variant: "sender" },
|
||||
});
|
||||
await wrapper.find(".wp-pair-badge").trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
const pop = document.querySelector(".wp-pair-pop");
|
||||
expect(pop).not.toBeNull();
|
||||
expect(pop?.textContent?.toLowerCase()).toContain("reach");
|
||||
// "next 2" is the human label for a next/count reach.
|
||||
expect(pop?.textContent).toContain("next 2");
|
||||
});
|
||||
|
||||
it("renders a PATH row when via.routeChain is present", async () => {
|
||||
const wrapper = mount(PairBadge, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
pair: senderPair(
|
||||
{ mode: "all" },
|
||||
{
|
||||
via: {
|
||||
carrierRowKey: "c1",
|
||||
carrierName: "backdrop",
|
||||
optionIds: ["o1"],
|
||||
routeChain: ["bbbbbbbb", "aaaaaaaa"],
|
||||
},
|
||||
},
|
||||
),
|
||||
variant: "sender",
|
||||
},
|
||||
});
|
||||
await wrapper.find(".wp-pair-badge").trigger("click");
|
||||
await wrapper.vm.$nextTick();
|
||||
const pop = document.querySelector(".wp-pair-pop");
|
||||
expect(pop).not.toBeNull();
|
||||
expect(pop?.textContent?.toLowerCase()).toContain("path");
|
||||
// Hops rendered (uuids resolve to themselves when no name cached).
|
||||
expect(pop?.textContent).toContain("bbbbbbbb");
|
||||
expect(pop?.textContent).toContain("aaaaaaaa");
|
||||
});
|
||||
});
|
||||
@@ -35,16 +35,21 @@ import { computed, nextTick, onBeforeUnmount, ref } from "vue";
|
||||
import type { PairingBadge } from "../../extension/constraint-pairs";
|
||||
import { cacheVersion, ensure, lookup } from "../../extension/preview-resolver";
|
||||
|
||||
type Variant = "sender" | "direct" | "carrier" | "option";
|
||||
type Variant = "sender" | "direct" | "carrier" | "option" | "collapse";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Single-pair variants (sender / direct / option / carrier-with-1) — the
|
||||
* one pair this chip represents. Carrier variant accepts the array
|
||||
* form via `pairs` instead. */
|
||||
* one pair this chip represents. Carrier + collapse variants accept the
|
||||
* array form via `pairs` instead. */
|
||||
pair?: PairingBadge;
|
||||
/** Multi-pair carrier variant — aggregate inbound via-pairs collapsed
|
||||
* into one `↪×N` chip. Ignored for non-carrier variants. */
|
||||
/** Multi-pair carrier / collapse variant — aggregate the pairs this chip
|
||||
* stands in for, collapsed into one chip. Ignored for single-pair
|
||||
* variants. */
|
||||
pairs?: readonly PairingBadge[];
|
||||
/** Optional explicit count for the `collapse` variant. When omitted the
|
||||
* chip falls back to `pairs.length`. Lets a caller render `↥×N` from a
|
||||
* total even if it hands a partial list to the popover. */
|
||||
count?: number;
|
||||
variant: Variant;
|
||||
}>();
|
||||
|
||||
@@ -52,7 +57,7 @@ const props = defineProps<{
|
||||
* a list (collapsed) or a single (legacy / single-inbound). Other
|
||||
* variants always single. */
|
||||
const pairList = computed<readonly PairingBadge[]>(() => {
|
||||
if (props.variant === "carrier") {
|
||||
if (props.variant === "carrier" || props.variant === "collapse") {
|
||||
if (props.pairs && props.pairs.length > 0) return props.pairs;
|
||||
if (props.pair) return [props.pair];
|
||||
return [];
|
||||
@@ -170,8 +175,77 @@ function routeLabel(p: PairingBadge): string {
|
||||
return "Via this wildcard's nested ref";
|
||||
}
|
||||
|
||||
/** Compact reach suffix for the SENDER chip, appended after `#N`.
|
||||
* ALWAYS shown — the default `all` reads `·all` so the chip never
|
||||
* hides which instances a constraint covers:
|
||||
* - all → `·all`
|
||||
* - first → `·first`
|
||||
* - next → `·n{count}` (count coerced, default 1 → `·n1`)
|
||||
* - pick → `·pick`
|
||||
* Returns "" for any non-sender variant (only the originating
|
||||
* constraint carries its own reach). */
|
||||
const reachSuffix = computed<string>(() => {
|
||||
if (props.variant !== "sender") return "";
|
||||
const reach = primaryPair.value?.reach;
|
||||
if (!reach) return "";
|
||||
switch (reach.mode) {
|
||||
case "first":
|
||||
return "·first";
|
||||
case "next": {
|
||||
const cnt = Number.isFinite(Number(reach.count)) ? Number(reach.count) : 1;
|
||||
return `·n${cnt}`;
|
||||
}
|
||||
case "pick":
|
||||
return "·pick";
|
||||
default:
|
||||
return "·all";
|
||||
}
|
||||
});
|
||||
|
||||
/** Human-readable reach label for the popover REACH row. Mirrors
|
||||
* `reachSuffix` but spelled out: `all` / `first` / `next {count}` /
|
||||
* `pick`. Null when there's no reach to show (non-sender pairs). */
|
||||
function reachLabel(p: PairingBadge): string | null {
|
||||
const reach = p.reach;
|
||||
if (!reach) return null;
|
||||
switch (reach.mode) {
|
||||
case "first":
|
||||
return "first";
|
||||
case "next": {
|
||||
const cnt = Number.isFinite(Number(reach.count)) ? Number(reach.count) : 1;
|
||||
return `next ${cnt}`;
|
||||
}
|
||||
case "pick":
|
||||
return "pick";
|
||||
default:
|
||||
return "all";
|
||||
}
|
||||
}
|
||||
|
||||
/** Hop labels for the popover PATH row, target LAST. Resolves each uuid
|
||||
* in `via.routeChain` to its display name via the preview-resolver
|
||||
* cache (rendered `@name`), falling back to the bare uuid when no name
|
||||
* is known yet. Empty when the pair has no routeChain. */
|
||||
function pathHops(p: PairingBadge): string[] {
|
||||
const chain = p.via?.routeChain;
|
||||
if (!chain || chain.length === 0) return [];
|
||||
return chain.map((uuid) => {
|
||||
const name = nameForUuid(uuid);
|
||||
return name ? `@${name}` : uuid;
|
||||
});
|
||||
}
|
||||
|
||||
const chipClasses = computed<string[]>(() => {
|
||||
const cls = ["wp-pair-badge"];
|
||||
// Collapse chip is its OWN solid surface (var(--wp-accent-300) fill via
|
||||
// the modifier) — no per-target color class, no multi-color fallback,
|
||||
// no carrier styling. Same base geometry (pad / font / radius) as a
|
||||
// direct pair badge, just a different paint. Short-circuit so none of
|
||||
// the color/carrier/orphan branches below apply.
|
||||
if (props.variant === "collapse") {
|
||||
cls.push("wp-pair-badge--collapse");
|
||||
return cls;
|
||||
}
|
||||
if (isMultiColor.value) {
|
||||
cls.push("wp-pair-badge--via-multi");
|
||||
} else if (primaryPair.value) {
|
||||
@@ -201,14 +275,43 @@ const chipClasses = computed<string[]>(() => {
|
||||
* against `×`, and keeps the glyph readable at chip-size. */
|
||||
const carrierText = computed<string>(() => `↪ ×${pairList.value.length}`);
|
||||
|
||||
/** Collapse chip count — explicit `count` prop wins, else the popover
|
||||
* list length. Drives the `↥×N` glyph that REPLACES the individual
|
||||
* contributor chips on a target row with 3+ contributors. */
|
||||
const collapseCount = computed<number>(() =>
|
||||
typeof props.count === "number" ? props.count : pairList.value.length,
|
||||
);
|
||||
const collapseText = computed<string>(() => `↥×${collapseCount.value}`);
|
||||
|
||||
const popoverAriaLabel = computed<string>(() => {
|
||||
if (props.variant === "carrier") {
|
||||
return `${pairList.value.length} constraint pair${pairList.value.length === 1 ? "" : "s"} via nested refs`;
|
||||
}
|
||||
if (props.variant === "collapse") {
|
||||
return `${collapseCount.value} constraint contributor${collapseCount.value === 1 ? "" : "s"}`;
|
||||
}
|
||||
return primaryPair.value
|
||||
? `Constraint pair ${primaryPair.value.number}`
|
||||
: "Constraint pair";
|
||||
});
|
||||
|
||||
/** True when the popover lists MORE THAN ONE pair (collapsed carrier or
|
||||
* the new collapse contributor cluster). Drives the `×N` head, the
|
||||
* per-entry `#N` head, and the multi-entry title/hint copy. */
|
||||
const isMultiEntry = computed<boolean>(
|
||||
() =>
|
||||
(props.variant === "carrier" || props.variant === "collapse")
|
||||
&& pairList.value.length > 1,
|
||||
);
|
||||
|
||||
/** Head title for the multi-entry popover. Collapse (contributor
|
||||
* cluster) reads "Contributing constraints"; the carrier route chip
|
||||
* keeps its "Routed constraint pairs" wording. */
|
||||
const multiEntryTitle = computed<string>(() =>
|
||||
props.variant === "collapse"
|
||||
? "Contributing constraints"
|
||||
: "Routed constraint pairs",
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -230,12 +333,15 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
>
|
||||
<template v-if="variant === 'sender' || variant === 'direct'">
|
||||
<span v-if="primaryPair?.isOrphan" class="wp-pair-badge__warn">!</span>
|
||||
<template v-if="variant === 'sender' && primaryPair?.via">#{{ primaryPair.number }} ↪</template>
|
||||
<template v-else>#{{ primaryPair?.number }}</template>
|
||||
<template v-if="variant === 'sender' && primaryPair?.via">#{{ primaryPair.number }} ↪{{ reachSuffix }}</template>
|
||||
<template v-else>#{{ primaryPair?.number }}{{ reachSuffix }}</template>
|
||||
</template>
|
||||
<template v-else-if="variant === 'carrier'">
|
||||
{{ carrierText }}
|
||||
</template>
|
||||
<template v-else-if="variant === 'collapse'">
|
||||
{{ collapseText }}
|
||||
</template>
|
||||
<template v-else>
|
||||
↪#{{ primaryPair?.number }}
|
||||
</template>
|
||||
@@ -252,10 +358,10 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
@mouseleave="onLeave"
|
||||
>
|
||||
<div class="wp-pair-pop__head">
|
||||
<span v-if="variant === 'carrier' && pairList.length > 1" class="wp-pair-pop__num">×{{ pairList.length }}</span>
|
||||
<span v-if="isMultiEntry" class="wp-pair-pop__num">×{{ pairList.length }}</span>
|
||||
<span v-else-if="primaryPair" class="wp-pair-pop__num">#{{ primaryPair.number }}</span>
|
||||
<span class="wp-pair-pop__title">
|
||||
{{ variant === "carrier" && pairList.length > 1 ? "Routed constraint pairs" : "Constraint pair" }}
|
||||
{{ isMultiEntry ? multiEntryTitle : "Constraint pair" }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -264,13 +370,25 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
class="wp-pair-pop__entry"
|
||||
:class="`wp-pair-pop__entry--c${p.colorIndex}`"
|
||||
>
|
||||
<div v-if="variant === 'carrier' && pairList.length > 1" class="wp-pair-pop__entry-head">
|
||||
<div v-if="isMultiEntry" class="wp-pair-pop__entry-head">
|
||||
<span class="wp-pair-pop__entry-num">#{{ p.number }}</span>
|
||||
</div>
|
||||
<div class="wp-pair-pop__row">
|
||||
<span class="wp-pair-pop__lbl">Route</span>
|
||||
<span class="wp-pair-pop__val">{{ routeLabel(p) }}</span>
|
||||
</div>
|
||||
<div v-if="reachLabel(p)" class="wp-pair-pop__row wp-pair-pop__row--reach">
|
||||
<span class="wp-pair-pop__lbl">Reach</span>
|
||||
<span class="wp-pair-pop__val wp-pair-pop__reach">{{ reachLabel(p) }}</span>
|
||||
</div>
|
||||
<div v-if="pathHops(p).length > 0" class="wp-pair-pop__row wp-pair-pop__row--path">
|
||||
<span class="wp-pair-pop__lbl">Path</span>
|
||||
<span class="wp-pair-pop__val wp-pair-pop__path">
|
||||
<template v-for="(hop, hi) in pathHops(p)" :key="`${hi}-${hop}`"><span
|
||||
class="wp-pair-pop__hop">{{ hop }}</span><span
|
||||
v-if="hi < pathHops(p).length - 1" class="wp-pair-pop__hop-arrow"> → </span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="wp-pair-pop__row">
|
||||
<span class="wp-pair-pop__lbl">Target</span>
|
||||
<span class="wp-pair-pop__val">
|
||||
@@ -296,6 +414,9 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
The constraint claims the next downstream wildcard instance with this uuid.
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="variant === 'collapse'" class="wp-pair-pop__hint">
|
||||
Every constraint above covers this target instance. Each applies its own reach + weighting when it fires.
|
||||
</div>
|
||||
<div v-else class="wp-pair-pop__hint">
|
||||
Each constraint fires when this wildcard rolls an option carrying its target ref.
|
||||
</div>
|
||||
@@ -312,7 +433,10 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
|
||||
.wp-pair-pop {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
/* Hover-card tier — above the popover tier (10020), since a pair badge can
|
||||
be rendered inside a popover and its card must clear it. Matches
|
||||
RefChip's hover card. */
|
||||
z-index: 10030;
|
||||
width: 280px;
|
||||
padding: 8px 10px;
|
||||
background: var(--wp-bg-1, var(--wp-bg2, #1a1d24));
|
||||
@@ -410,6 +534,23 @@ const popoverAriaLabel = computed<string>(() => {
|
||||
color: var(--wp-text-dim, var(--wp-text-muted, #8a8d99));
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* Reach row — green so the constraint's coverage selector reads as a
|
||||
* distinct, positive fact in the card (mirrors the green reach accent
|
||||
* the reach-selector UI uses elsewhere). */
|
||||
.wp-pair-pop__reach {
|
||||
color: var(--wp-green, #6bc96f);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Path row — the transitive hop chain a constraint travels to reach a
|
||||
* nested target, target last. Wraps + uses a dim arrow between hops. */
|
||||
.wp-pair-pop__path {
|
||||
display: inline;
|
||||
word-break: break-word;
|
||||
}
|
||||
.wp-pair-pop__hop-arrow {
|
||||
color: var(--wp-text-dim, #7a7d88);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Color-band the head badge color to match the chip. */
|
||||
.wp-pair-pop--c1 .wp-pair-pop__num { color: var(--wp-var-1); }
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* PushBundleToLibraryModal — bundle re-link (D3a bundle side).
|
||||
*
|
||||
* A workflow BundleInstance is "detached" when its `library_id` is no longer
|
||||
* in the polled `bundleHashes` map (a re-import minted a fresh uuid). Its
|
||||
* `inserted_at_hash` IS the library bundle's payload_hash at insert time, so
|
||||
* a content-identical library bundle under a DIFFERENT uuid matches on it —
|
||||
* the same signal the module re-link uses.
|
||||
*/
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
vi.mock("#comfyui/app", () => ({
|
||||
app: { graph: { _nodes: [], _nodes_by_id: {} } },
|
||||
}));
|
||||
|
||||
import PushBundleToLibraryModal from "./PushBundleToLibraryModal.vue";
|
||||
import { bundleHashes } from "./drift-store";
|
||||
import type { BundleInstance } from "../../widgets/_shared";
|
||||
|
||||
function makeBundle(over: Partial<BundleInstance> = {}): BundleInstance {
|
||||
return {
|
||||
_uid: "buid0001",
|
||||
library_id: "dead0001",
|
||||
name: "Environments",
|
||||
color: null,
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
parent_uid: null,
|
||||
start_idx: 0,
|
||||
end_idx: 0,
|
||||
inserted_at_hash: "HASH_A",
|
||||
...over,
|
||||
} as BundleInstance;
|
||||
}
|
||||
|
||||
function resetBody(): void {
|
||||
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetBody();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
resetBody();
|
||||
bundleHashes.value = null;
|
||||
});
|
||||
|
||||
async function openModal(bundle: BundleInstance | null) {
|
||||
const wrap = mount(PushBundleToLibraryModal, {
|
||||
attachTo: document.body,
|
||||
props: { open: false, bundle: null, childrenForLibrary: [], childrenPreview: [] },
|
||||
});
|
||||
await wrap.setProps({ open: true, bundle, childrenForLibrary: [], childrenPreview: [] });
|
||||
await flushPromises();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
describe("PushBundleToLibraryModal re-link", () => {
|
||||
it("offers re-link to a content-identical library bundle when detached", async () => {
|
||||
// A DIFFERENT uuid carries this instance's inserted_at_hash.
|
||||
bundleHashes.value = { live0001: "HASH_A" };
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/wp/api/bundles")) {
|
||||
return new Response(
|
||||
JSON.stringify({ items: [{ id: "live0001", name: "Environments" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}));
|
||||
|
||||
const wrap = await openModal(makeBundle());
|
||||
await flushPromises();
|
||||
|
||||
expect(document.querySelector('[data-test="pbtl-relink"]')).not.toBeNull();
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-test="pbtl-relink-confirm"]')!.click();
|
||||
await flushPromises();
|
||||
|
||||
const saved = wrap.emitted("saved");
|
||||
expect(saved).toBeTruthy();
|
||||
const result = saved![0][0] as { mode: string; id: string; origId: string };
|
||||
expect(result.mode).toBe("relink");
|
||||
expect(result.id).toBe("live0001");
|
||||
expect(result.origId).toBe("dead0001");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does NOT offer re-link when the bundle is still linked", async () => {
|
||||
// library_id present in the live map → not detached.
|
||||
bundleHashes.value = { dead0001: "HASH_A" };
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 })));
|
||||
const wrap = await openModal(makeBundle());
|
||||
await flushPromises();
|
||||
expect(document.querySelector('[data-test="pbtl-relink"]')).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does NOT offer re-link when no library bundle matches the content", async () => {
|
||||
bundleHashes.value = { live0009: "OTHER_HASH" };
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/wp/api/bundles")) {
|
||||
return new Response(
|
||||
JSON.stringify({ items: [{ id: "live0009", name: "Unrelated" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}));
|
||||
const wrap = await openModal(makeBundle({ name: "Environments" }));
|
||||
await flushPromises();
|
||||
expect(document.querySelector('[data-test="pbtl-relink"]')).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,14 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { bundleHashes } from "./drift-store";
|
||||
import { api } from "../../manager/api/client";
|
||||
import {
|
||||
findRelinkCandidates,
|
||||
autoRelinkTarget,
|
||||
type RelinkCandidate,
|
||||
} from "../../manager/import-export/relink-match";
|
||||
import { bundleContentKey } from "./bundles/bundle-fingerprint";
|
||||
import type { BundleInstance } from "../../widgets/_shared";
|
||||
import WpCheck from "@/components/shared/WpCheck.vue";
|
||||
|
||||
interface ChildPreview {
|
||||
name: string;
|
||||
@@ -50,10 +57,12 @@ interface SaveResult {
|
||||
* "(copy)" suffix; workflow row keeps its old library_id. `reattach`
|
||||
* — POST when source entry was missing; no copy suffix, caller
|
||||
* should rebind the workflow row's library_id to the new uuid so
|
||||
* MISSING clears. */
|
||||
mode: "update" | "fork" | "reattach";
|
||||
/** New library uuid when forking/reattaching; same as bundle.library_id
|
||||
* when updating. */
|
||||
* MISSING clears. `relink` — no write: point the detached instance at
|
||||
* an EXISTING content-identical library bundle; caller rebinds
|
||||
* library_id + inserted_at_hash. */
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
/** New library uuid when forking/reattaching/re-linking; same as
|
||||
* bundle.library_id when updating. */
|
||||
id: string;
|
||||
/** payload_hash returned by the server. */
|
||||
payload_hash: string;
|
||||
@@ -121,10 +130,120 @@ const isLibraryMissing = computed(() => {
|
||||
return !(props.bundle.library_id in bundleHashes.value);
|
||||
});
|
||||
|
||||
/** Whether to OFFER the re-link picker — the bundle-side mirror of the module
|
||||
* modal's `needsRelink`. Wider than `isLibraryMissing`, which additionally
|
||||
* requires a `library_id`: a bundle that lost its link (shared workflow,
|
||||
* stale cache) still corresponds to a library entry, and matching it on child
|
||||
* content is what `bundleContentKey` is for. Costs nothing when there is no
|
||||
* match — the picker only renders once candidates come back. */
|
||||
const needsRelink = computed(() => {
|
||||
const b = props.bundle;
|
||||
if (!b || bundleHashes.value === null) return false;
|
||||
return !b.library_id || !(b.library_id in bundleHashes.value);
|
||||
});
|
||||
|
||||
const canUpdateExisting = computed(
|
||||
() => isLibraryTracked.value && !isLibraryMissing.value,
|
||||
);
|
||||
|
||||
/** uuid -> display name for every live library bundle, fetched once when the
|
||||
* instance turns out to be detached. `bundleHashes` carries the content hash
|
||||
* but not names, so the re-link picker needs this to label + weakly-match. */
|
||||
const relinkNames = ref<Map<string, { name: string; type: string }>>(new Map());
|
||||
|
||||
/** uuid → child-content key for every live library bundle. Only consulted when
|
||||
* the instance carries no `inserted_at_hash`. */
|
||||
const relinkContentKeys = ref<Map<string, string>>(new Map());
|
||||
|
||||
async function fetchBundleNames(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/wp/api/bundles");
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as
|
||||
| { items?: Array<{ id?: string; name?: string; children?: unknown }> }
|
||||
| Array<{ id?: string; name?: string; children?: unknown }>;
|
||||
const items = Array.isArray(data) ? data : (data.items ?? []);
|
||||
const next = new Map<string, { name: string; type: string }>();
|
||||
const keys = new Map<string, string>();
|
||||
for (const it of items) {
|
||||
if (typeof it.id === "string" && it.id) {
|
||||
next.set(it.id, { name: it.name ?? "", type: "bundle" });
|
||||
if (Array.isArray(it.children)) {
|
||||
keys.set(it.id, bundleContentKey(
|
||||
it.children as Array<{ type?: string; payload_hash?: string }>,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
relinkNames.value = next;
|
||||
relinkContentKeys.value = keys;
|
||||
} catch {
|
||||
/* network — leave empty; no re-link offered */
|
||||
}
|
||||
}
|
||||
|
||||
/** Content-aware re-link candidates for a DETACHED bundle instance. The
|
||||
* instance's `inserted_at_hash` IS the library bundle's payload_hash at
|
||||
* insert time, so an identical-content library bundle under a different uuid
|
||||
* matches on it exactly — the same signal the module re-link uses. */
|
||||
const relinkCandidates = computed<RelinkCandidate[]>(() => {
|
||||
const b = props.bundle;
|
||||
if (!b || !needsRelink.value || bundleHashes.value === null) return [];
|
||||
const live: Record<string, { type?: string; payload_hash: string; contentKey?: string }> = {};
|
||||
for (const [uuid, h] of Object.entries(bundleHashes.value)) {
|
||||
live[uuid] = { type: "bundle", payload_hash: h, contentKey: relinkContentKeys.value.get(uuid) };
|
||||
}
|
||||
return findRelinkCandidates(
|
||||
{
|
||||
id: b.library_id,
|
||||
type: "bundle",
|
||||
payload_hash: b.inserted_at_hash,
|
||||
// Only when there's no stored hash to compare — the server hash stays
|
||||
// authoritative whenever it exists.
|
||||
contentKey: b.inserted_at_hash
|
||||
? undefined
|
||||
: bundleContentKey(
|
||||
props.childrenForLibrary as Array<{ type?: string; payload_hash?: string }>,
|
||||
),
|
||||
name: b.name ?? "",
|
||||
},
|
||||
live,
|
||||
(u) => relinkNames.value.get(u),
|
||||
);
|
||||
});
|
||||
|
||||
const relinkAuto = computed(() => autoRelinkTarget(relinkCandidates.value));
|
||||
const relinkPick = ref<RelinkCandidate | null>(null);
|
||||
const relinkQuery = ref<string>("");
|
||||
const relinkFiltered = computed<RelinkCandidate[]>(() => {
|
||||
const q = relinkQuery.value.trim().toLowerCase();
|
||||
if (!q) return relinkCandidates.value;
|
||||
return relinkCandidates.value.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.uuid.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
const relinkTarget = computed<RelinkCandidate | null>(
|
||||
() => relinkPick.value ?? relinkAuto.value,
|
||||
);
|
||||
|
||||
/** Danger tone only when nothing can be reconnected — a re-linkable bundle is
|
||||
* a routine state, and red steered users toward the duplicate-making path. */
|
||||
const relinkNoteClass = computed(() =>
|
||||
relinkCandidates.value.length > 0 ? "wp-ptl-note--info" : "wp-ptl-note--danger",
|
||||
);
|
||||
|
||||
function doRelink(): void {
|
||||
const t = relinkTarget.value;
|
||||
if (!t || !props.bundle) return;
|
||||
emit("saved", {
|
||||
mode: "relink",
|
||||
id: t.uuid,
|
||||
payload_hash: t.payloadHash,
|
||||
name: t.name,
|
||||
origId: props.bundle.library_id,
|
||||
});
|
||||
}
|
||||
|
||||
const tagsParsed = computed<string[]>(() =>
|
||||
tagsText.value
|
||||
.split(",")
|
||||
@@ -143,8 +262,17 @@ function resetForm(): void {
|
||||
// into library, so we make the safer choice the default and let the
|
||||
// user opt out.
|
||||
cascadeEnabled.value = cascadeAvailable.value;
|
||||
relinkPick.value = null;
|
||||
relinkQuery.value = "";
|
||||
}
|
||||
|
||||
// Re-link candidates only matter for a DETACHED instance — skip the extra
|
||||
// library-list fetch when the bundle is tracked + present. A drift poll can
|
||||
// flip it to detached after open, so also fetch the first time that happens.
|
||||
watch(needsRelink, (missing) => {
|
||||
if (props.open && missing && relinkNames.value.size === 0) void fetchBundleNames();
|
||||
});
|
||||
|
||||
/** Seed description + tags from the live library entry when the
|
||||
* bundle is tracked. Skips on missing — the entry is gone. Only
|
||||
* fills empty form fields so a mid-edit doesn't get clobbered. */
|
||||
@@ -169,6 +297,7 @@ watch(
|
||||
if (!isOpen) return;
|
||||
resetForm();
|
||||
void seedFromLibrary();
|
||||
if (needsRelink.value) void fetchBundleNames();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -393,11 +522,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
class="wp-ptl-cascade"
|
||||
data-test="pbtl-cascade"
|
||||
>
|
||||
<label class="wp-ptl-cascade__toggle">
|
||||
<input
|
||||
<span class="wp-ptl-cascade__toggle">
|
||||
<WpCheck
|
||||
v-model="cascadeEnabled"
|
||||
type="checkbox"
|
||||
data-test="pbtl-cascade-toggle"
|
||||
aria-label="Restore missing references first"
|
||||
/>
|
||||
<span>
|
||||
Restore
|
||||
@@ -408,7 +537,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
: "s"
|
||||
}} first
|
||||
</span>
|
||||
</label>
|
||||
</span>
|
||||
<div class="wp-ptl-cascade__hint">
|
||||
Cascade-creates fresh library entries for every missing
|
||||
child (bottom-up — modules first, then inner bundles)
|
||||
@@ -437,10 +566,68 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="isLibraryMissing" class="wp-ptl-note wp-ptl-note--danger" data-test="pbtl-missing">
|
||||
The library entry for this bundle has been deleted upstream.
|
||||
"Update existing" is unavailable — use "Save as new entry" to
|
||||
re-add it to the library.
|
||||
<div
|
||||
v-if="needsRelink && relinkCandidates.length"
|
||||
class="wp-ptl-relink"
|
||||
data-test="pbtl-relink"
|
||||
>
|
||||
<div class="wp-ptl-relink__title">Re-link to an existing library bundle</div>
|
||||
<p class="wp-ptl-relink__hint">
|
||||
This bundle's content matches a library entry under a different id (a re-import
|
||||
minted a new one). Re-link to reconnect drift + reset instead of adding a duplicate.
|
||||
</p>
|
||||
<input
|
||||
v-if="relinkCandidates.length > 1"
|
||||
v-model="relinkQuery"
|
||||
type="text"
|
||||
class="wp-ptl-input"
|
||||
data-test="pbtl-relink-search"
|
||||
placeholder="Search by name or id…"
|
||||
aria-label="Search library bundles to re-link"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ul class="wp-ptl-relink__list">
|
||||
<li
|
||||
v-for="c in relinkFiltered"
|
||||
:key="c.uuid"
|
||||
class="wp-ptl-relink__cand"
|
||||
:class="{ 'is-picked': relinkTarget != null && relinkTarget.uuid === c.uuid }"
|
||||
data-test="pbtl-relink-candidate"
|
||||
@click="relinkPick = c"
|
||||
>
|
||||
<span class="wp-ptl-relink__name">{{ c.name || "(unnamed)" }}</span>
|
||||
<code class="wp-ptl-relink__uuid">{{ c.uuid }}</code>
|
||||
<span
|
||||
class="wp-ptl-relink__tag"
|
||||
:class="c.contentIdentical ? 'is-identical' : 'is-diff'"
|
||||
>{{ c.contentIdentical ? "identical" : "content differs" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-ptl-btn wp-ptl-btn--primary"
|
||||
data-test="pbtl-relink-confirm"
|
||||
:disabled="!relinkTarget || undefined"
|
||||
@click="doRelink"
|
||||
>Re-link<template v-if="relinkTarget"> to “{{ relinkTarget.name || relinkTarget.uuid }}”</template></button>
|
||||
</div>
|
||||
|
||||
<!-- States the observable fact, not a guessed cause — see the
|
||||
matching note in PushToLibraryModal. Danger tone only when
|
||||
there is genuinely nothing to reconnect to. -->
|
||||
<div v-if="needsRelink" class="wp-ptl-note" :class="relinkNoteClass" data-test="pbtl-missing">
|
||||
<template v-if="relinkCandidates.length > 0">
|
||||
This bundle isn’t linked to any library entry under its recorded id.
|
||||
{{ relinkTarget?.contentIdentical
|
||||
? "A library bundle with identical children is selected above — re-linking reconnects them."
|
||||
: "Pick a match above to re-link, or save it as a new entry." }}
|
||||
</template>
|
||||
<template v-else>
|
||||
This bundle isn’t linked to any library entry under its recorded id, and
|
||||
nothing in the library matches its children or name. “Update existing” is
|
||||
unavailable — “Save as new entry” adds it to the library.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="wp-ptl-error" data-test="pbtl-error">
|
||||
@@ -537,7 +724,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
font-family: inherit;
|
||||
}
|
||||
.wp-ptl-input:focus { outline: 1px solid var(--wp-accent-500, #8b5cf6); }
|
||||
.wp-ptl-input--multi { resize: vertical; min-height: 48px; }
|
||||
.wp-ptl-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 48px; }
|
||||
.wp-ptl-children {
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
@@ -629,6 +816,50 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 32%, transparent);
|
||||
color: var(--wp-danger, #ef4444);
|
||||
}
|
||||
/* Informational tone for the "pick a match" case — mirrors PushToLibraryModal. */
|
||||
.wp-ptl-note--info {
|
||||
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
|
||||
color: var(--wp-text, #e7e7ee);
|
||||
}
|
||||
.wp-ptl-relink {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 8%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wp-ptl-relink__title { font-size: 12.5px; font-weight: 600; color: var(--wp-text, #e7e7ee); }
|
||||
.wp-ptl-relink__hint { margin: 0; font-size: 11.5px; color: var(--wp-text-muted, #a1a1ad); }
|
||||
.wp-ptl-relink__list {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
max-height: 160px; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.wp-ptl-relink__cand {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
padding: 5px 8px; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.wp-ptl-relink__cand:hover { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 16%, transparent); }
|
||||
.wp-ptl-relink__cand.is-picked { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 28%, transparent); }
|
||||
.wp-ptl-relink__name { font-size: 12px; color: var(--wp-text, #e7e7ee); }
|
||||
.wp-ptl-relink__uuid { font-family: var(--wp-font-mono, monospace); font-size: 10px; color: var(--wp-text-dim, #6e6e7c); }
|
||||
.wp-ptl-relink__tag {
|
||||
margin-left: auto; flex-shrink: 0;
|
||||
font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
}
|
||||
.wp-ptl-relink__tag.is-identical {
|
||||
color: var(--wp-green, #6ad28b);
|
||||
background: color-mix(in oklab, var(--wp-green, #6ad28b) 16%, transparent);
|
||||
}
|
||||
.wp-ptl-relink__tag.is-diff {
|
||||
color: var(--wp-warn, #facc15);
|
||||
background: color-mix(in oklab, var(--wp-warn, #facc15) 16%, transparent);
|
||||
}
|
||||
.wp-ptl-relink .wp-ptl-btn--primary { align-self: flex-start; }
|
||||
.wp-ptl-error {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("#comfyui/app", () => ({
|
||||
}));
|
||||
|
||||
import PushToLibraryModal from "./PushToLibraryModal.vue";
|
||||
import { hashes } from "./drift-store";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
const VALID_PAYLOAD = {
|
||||
@@ -86,6 +87,9 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
resetBody();
|
||||
// The re-link branch reads the drift-store live hashes; null it out so a
|
||||
// set value can't leak into other tests and falsely trip isLibraryMissing.
|
||||
hashes.value = null;
|
||||
});
|
||||
|
||||
async function openModal(draft: ModuleEntry | null) {
|
||||
@@ -247,15 +251,50 @@ describe("PushToLibraryModal", () => {
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("offers re-link to a content-identical library row when the draft is detached", async () => {
|
||||
// A DIFFERENT uuid carries the draft's payload_hash — the re-imported twin.
|
||||
hashes.value = { live0001: { type: "wildcard", payload_hash: "HASH_A" } };
|
||||
pushResponse({ items: [] }); // fetchBundlesContaining
|
||||
pushResponse({ description: "", tags: [] }); // seedFromLibrary
|
||||
pushResponse({ items: [{ id: "live0001", name: "test_w", type: "wildcard" }] }); // fetchLibraryNames
|
||||
const wrap = await openModal(makeDraft({ id: "dead0001", payload_hash: "HASH_A" }));
|
||||
|
||||
const relink = document.querySelector('[data-test="ptl-relink"]');
|
||||
expect(relink).not.toBeNull();
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-test="ptl-relink-confirm"]')!.click();
|
||||
await flushPromises();
|
||||
|
||||
const saved = wrap.emitted("saved");
|
||||
expect(saved).toBeTruthy();
|
||||
const result = saved![0][0] as { mode: string; id: string; origId: string };
|
||||
expect(result.mode).toBe("relink");
|
||||
expect(result.id).toBe("live0001");
|
||||
expect(result.origId).toBe("dead0001");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does not offer re-link when no content match exists (detached, novel content)", async () => {
|
||||
// Library has a different-content row only → no identical + no name match
|
||||
// for a differently-named draft → no re-link block (Save as new is the path).
|
||||
hashes.value = { live0009: { type: "wildcard", payload_hash: "OTHER" } };
|
||||
pushResponse({ items: [] }); // bundles
|
||||
pushResponse({ description: "", tags: [] }); // seedFromLibrary
|
||||
pushResponse({ items: [{ id: "live0009", name: "unrelated", type: "wildcard" }] }); // names
|
||||
const wrap = await openModal(makeDraft({ id: "dead0002", payload_hash: "HASH_Z" }));
|
||||
expect(document.querySelector('[data-test="ptl-relink"]')).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("opt-out checkbox round-trips into the PUT body", async () => {
|
||||
pushResponse({ items: [{ id: "b1", name: "framing" }] });
|
||||
pushResponse({ description: "", tags: [] }); // seedFromLibrary
|
||||
pushResponse({ payload_hash: "h", bundles_updated: [], name: "test_w" });
|
||||
const wrap = await openModal(makeDraft());
|
||||
|
||||
const cb = document.querySelector<HTMLInputElement>('[data-test="ptl-propagate"]')!;
|
||||
cb.checked = false;
|
||||
cb.dispatchEvent(new Event("change"));
|
||||
// propagate defaults on; one click toggles it off (wp-check span).
|
||||
const cb = document.querySelector<HTMLElement>('[data-test="ptl-propagate"]')!;
|
||||
cb.click();
|
||||
await flushPromises();
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-test="ptl-update"]')!.click();
|
||||
|
||||
@@ -27,8 +27,15 @@ import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { workflowSiblingCount } from "./duplicates/sibling-count";
|
||||
import { forkModule } from "./duplicates/fork";
|
||||
import { hashes as libraryHashes } from "./drift-store";
|
||||
import {
|
||||
findRelinkCandidates,
|
||||
autoRelinkTarget,
|
||||
type RelinkCandidate,
|
||||
} from "../../manager/import-export/relink-match";
|
||||
import { localPayloadFingerprint } from "../../manager/import-export/fingerprint";
|
||||
import { app } from "#comfyui/app";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import WpCheck from "@/components/shared/WpCheck.vue";
|
||||
|
||||
interface Bundle {
|
||||
id: string;
|
||||
@@ -40,8 +47,10 @@ interface SaveResult {
|
||||
* entry with a "(copy)" suffix; workflow row keeps its old id.
|
||||
* `reattach` — fork when the source entry was missing upstream;
|
||||
* no copy suffix, AND the caller should rebind the workflow row to
|
||||
* the new uuid so MISSING clears. */
|
||||
mode: "update" | "fork" | "reattach";
|
||||
* the new uuid so MISSING clears. `relink` — no write at all: point
|
||||
* the detached workflow row at an EXISTING content-identical library
|
||||
* uuid; the caller swaps the row id + remaps sibling refs. */
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
/** New library uuid when forking; same as draft.id when updating. */
|
||||
id: string;
|
||||
/** payload_hash returned by the server after the write. */
|
||||
@@ -87,6 +96,22 @@ const isLibraryMissing = computed(() => {
|
||||
return !(props.draft.id in libraryHashes.value);
|
||||
});
|
||||
|
||||
/** Whether to OFFER the re-link picker.
|
||||
*
|
||||
* Wider than `isLibraryMissing`, which requires a payload_hash. A hash-less
|
||||
* row was treated as "never library-tracked, nothing to re-link" — true for a
|
||||
* module authored in the widget, wrong for the cases that actually bite: a
|
||||
* shared workflow, or a row whose stored hash was lost. Those still correspond
|
||||
* to a library entry, and matching them is what `contentKey` is for.
|
||||
*
|
||||
* Costs nothing when there IS no match: the picker only renders when
|
||||
* candidates come back, so a genuinely new module still sees the plain
|
||||
* save-as-new flow. */
|
||||
const needsRelink = computed(() => {
|
||||
if (!props.draft || libraryHashes.value === null) return false;
|
||||
return !(props.draft.id in libraryHashes.value);
|
||||
});
|
||||
|
||||
/** "Update existing" is enabled only when the entry exists AND is
|
||||
* library-tracked. Disabled when never library-tracked OR when the
|
||||
* upstream entry has been deleted. */
|
||||
@@ -94,6 +119,107 @@ const canUpdateExisting = computed(
|
||||
() => isLibraryTracked.value && !isLibraryMissing.value,
|
||||
);
|
||||
|
||||
/** uuid → {name, type} for every live library module, fetched once on open.
|
||||
* Backs the re-link candidate name lookup + the (type, name) fallback match.
|
||||
* drift-store hashes carry (type, payload_hash) but not names, so the picker
|
||||
* needs this to label + weakly-match candidates. */
|
||||
const relinkNames = ref<Map<string, { name: string; type: string }>>(new Map());
|
||||
|
||||
/** uuid → locally computed payload fingerprint for every live library module.
|
||||
* Only consulted for a HASH-LESS draft, where the server hash can't decide.
|
||||
* Both sides are fingerprinted by the same local function, so this needs no
|
||||
* parity with the engine's own hashing. */
|
||||
const relinkContentKeys = ref<Map<string, string>>(new Map());
|
||||
|
||||
async function fetchLibraryNames(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/wp/api/modules");
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as
|
||||
| { items?: Array<{ id?: string; name?: string; type?: string; payload?: unknown }> }
|
||||
| Array<{ id?: string; name?: string; type?: string; payload?: unknown }>;
|
||||
const items = Array.isArray(data) ? data : (data.items ?? []);
|
||||
const next = new Map<string, { name: string; type: string }>();
|
||||
const keys = new Map<string, string>();
|
||||
for (const it of items) {
|
||||
if (typeof it.id === "string" && it.id) {
|
||||
next.set(it.id, { name: it.name ?? "", type: it.type ?? "" });
|
||||
if (it.payload !== undefined) {
|
||||
keys.set(it.id, localPayloadFingerprint(it.payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
relinkNames.value = next;
|
||||
relinkContentKeys.value = keys;
|
||||
} catch {
|
||||
/* network — leave the maps empty; no re-link offered */
|
||||
}
|
||||
}
|
||||
|
||||
/** Content-aware re-link candidates for a DETACHED draft: existing library
|
||||
* rows whose (type, payload_hash) match the draft's content (identical) or
|
||||
* whose (type, name) match (content differs). Empty unless the draft is
|
||||
* detached (`isLibraryMissing`). */
|
||||
const relinkCandidates = computed<RelinkCandidate[]>(() => {
|
||||
if (!props.draft || !needsRelink.value || libraryHashes.value === null) return [];
|
||||
// Merge the locally computed content keys into the hash map the matcher
|
||||
// walks, so a hash-less draft can still match on payload content.
|
||||
const live: Record<string, { type?: string; payload_hash: string; contentKey?: string }> = {};
|
||||
for (const [uuid, entry] of Object.entries(libraryHashes.value)) {
|
||||
live[uuid] = { ...entry, contentKey: relinkContentKeys.value.get(uuid) };
|
||||
}
|
||||
return findRelinkCandidates(
|
||||
{
|
||||
id: props.draft.id,
|
||||
type: props.draft.type,
|
||||
payload_hash: props.draft.payload_hash,
|
||||
contentKey: props.draft.payload_hash
|
||||
? undefined
|
||||
: localPayloadFingerprint(props.draft.payload),
|
||||
name: props.draft.meta?.name ?? "",
|
||||
},
|
||||
live,
|
||||
(u) => relinkNames.value.get(u),
|
||||
);
|
||||
});
|
||||
|
||||
/** The sole content-identical candidate, if unambiguous (else null). */
|
||||
const relinkAuto = computed(() => autoRelinkTarget(relinkCandidates.value));
|
||||
const relinkPick = ref<RelinkCandidate | null>(null);
|
||||
const relinkQuery = ref<string>("");
|
||||
const relinkFiltered = computed<RelinkCandidate[]>(() => {
|
||||
const q = relinkQuery.value.trim().toLowerCase();
|
||||
if (!q) return relinkCandidates.value;
|
||||
return relinkCandidates.value.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.uuid.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
/** The active re-link target: an explicit pick wins, else the unambiguous
|
||||
* auto-target (single identical candidate). */
|
||||
const relinkTarget = computed<RelinkCandidate | null>(
|
||||
() => relinkPick.value ?? relinkAuto.value,
|
||||
);
|
||||
|
||||
/** Danger tone only when there is genuinely nothing to reconnect to. With a
|
||||
* match on offer this is a routine "pick one" prompt, not a failure. */
|
||||
const relinkNoteClass = computed(() =>
|
||||
relinkCandidates.value.length > 0 ? "wp-ptl-note--info" : "wp-ptl-note--danger",
|
||||
);
|
||||
|
||||
function doRelink(): void {
|
||||
const t = relinkTarget.value;
|
||||
if (!t || !props.draft) return;
|
||||
emit("saved", {
|
||||
mode: "relink",
|
||||
id: t.uuid,
|
||||
payload_hash: t.payloadHash,
|
||||
bundles_updated: [],
|
||||
name: t.name,
|
||||
origId: props.draft.id,
|
||||
});
|
||||
}
|
||||
|
||||
const siblings = computed<number>(() => {
|
||||
if (!props.draft) return 0;
|
||||
try {
|
||||
@@ -132,6 +258,8 @@ function resetForm(): void {
|
||||
propagate.value = true;
|
||||
busy.value = false;
|
||||
errorMsg.value = "";
|
||||
relinkPick.value = null;
|
||||
relinkQuery.value = "";
|
||||
}
|
||||
|
||||
/** When the row is library-tracked, fetch the live library entry and
|
||||
@@ -183,9 +311,19 @@ watch(
|
||||
resetForm();
|
||||
void fetchBundlesContaining();
|
||||
void seedFromLibrary();
|
||||
// Re-link candidates only matter when the row's id ISN'T in the library —
|
||||
// skip the extra library-list fetch entirely when it's present.
|
||||
if (needsRelink.value) void fetchLibraryNames();
|
||||
},
|
||||
);
|
||||
|
||||
// A drift poll can flip the draft to unlinked AFTER open (hashes land late);
|
||||
// fetch the candidate map the first time that happens so re-link appears
|
||||
// without a reopen. One-shot: only when open, unlinked, and not yet loaded.
|
||||
watch(needsRelink, (unlinked) => {
|
||||
if (props.open && unlinked && relinkNames.value.size === 0) void fetchLibraryNames();
|
||||
});
|
||||
|
||||
function close(): void {
|
||||
if (busy.value) return;
|
||||
emit("close");
|
||||
@@ -395,14 +533,14 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
<ul class="wp-ptl-bundles__list">
|
||||
<li v-for="b in bundles" :key="b.id">{{ b.name }}</li>
|
||||
</ul>
|
||||
<label class="wp-ptl-checkbox">
|
||||
<input
|
||||
<span class="wp-ptl-checkbox">
|
||||
<WpCheck
|
||||
v-model="propagate"
|
||||
type="checkbox"
|
||||
data-test="ptl-propagate"
|
||||
aria-label="Propagate update to those bundles"
|
||||
/>
|
||||
Propagate update to those bundles
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="siblings > 1" class="wp-ptl-note wp-ptl-note--warn" data-test="ptl-siblings">
|
||||
@@ -410,10 +548,82 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
changes the library entry every instance reads from.
|
||||
</div>
|
||||
|
||||
<div v-if="isLibraryMissing" class="wp-ptl-note wp-ptl-note--danger" data-test="ptl-missing">
|
||||
The library entry for this module has been deleted upstream. "Update existing"
|
||||
is unavailable — use "Save as new entry" to re-add it to the library as a fresh
|
||||
entry.
|
||||
<div
|
||||
v-if="needsRelink && relinkCandidates.length"
|
||||
class="wp-ptl-relink"
|
||||
data-test="ptl-relink"
|
||||
>
|
||||
<div class="wp-ptl-relink__title">Re-link to an existing library entry</div>
|
||||
<p class="wp-ptl-relink__hint">
|
||||
This module's content matches a library entry under a different id (a re-import
|
||||
minted a new one). Re-link to reconnect drift + refresh instead of adding a duplicate.
|
||||
</p>
|
||||
<input
|
||||
v-if="relinkCandidates.length > 1"
|
||||
v-model="relinkQuery"
|
||||
type="text"
|
||||
class="wp-ptl-input"
|
||||
data-test="ptl-relink-search"
|
||||
placeholder="Search by name or id…"
|
||||
aria-label="Search library entries to re-link"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ul class="wp-ptl-relink__list">
|
||||
<li
|
||||
v-for="c in relinkFiltered"
|
||||
:key="c.uuid"
|
||||
class="wp-ptl-relink__cand"
|
||||
:class="{ 'is-picked': relinkTarget != null && relinkTarget.uuid === c.uuid }"
|
||||
data-test="ptl-relink-candidate"
|
||||
@click="relinkPick = c"
|
||||
>
|
||||
<span class="wp-ptl-relink__name">{{ c.name || "(unnamed)" }}</span>
|
||||
<code class="wp-ptl-relink__uuid">{{ c.uuid }}</code>
|
||||
<!-- "identical (content)" distinguishes a match established
|
||||
by locally fingerprinting the payload — the hash-less
|
||||
path — from one the server's own hash confirmed. Same
|
||||
confidence in practice, but the label shouldn't imply
|
||||
the authoritative hash agreed when there wasn't one. -->
|
||||
<span
|
||||
class="wp-ptl-relink__tag"
|
||||
:class="c.contentIdentical ? 'is-identical' : 'is-diff'"
|
||||
:title="c.matchedByContent
|
||||
? 'Matched by comparing payload content — this module carries no stored hash'
|
||||
: undefined"
|
||||
>{{ c.contentIdentical
|
||||
? (c.matchedByContent ? "identical (content)" : "identical")
|
||||
: "content differs" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-ptl-btn wp-ptl-btn--primary"
|
||||
data-test="ptl-relink-confirm"
|
||||
:disabled="!relinkTarget || undefined"
|
||||
@click="doRelink"
|
||||
>Re-link<template v-if="relinkTarget"> to “{{ relinkTarget.name || relinkTarget.uuid }}”</template></button>
|
||||
</div>
|
||||
|
||||
<!-- States the OBSERVABLE fact and points at Re-link first when a
|
||||
match exists. The old copy asserted the entry "has been
|
||||
deleted upstream" — a cause it cannot know (a shared workflow,
|
||||
a re-import under a new id, or a lost hash all look identical
|
||||
from here) — and recommended "Save as new entry" while the
|
||||
Re-link panel sat directly above offering the better option,
|
||||
which is how duplicates got made. -->
|
||||
<div v-if="needsRelink" class="wp-ptl-note" :class="relinkNoteClass" data-test="ptl-missing">
|
||||
<template v-if="relinkCandidates.length > 0">
|
||||
This module isn’t linked to any library entry under its recorded id.
|
||||
{{ relinkTarget?.contentIdentical
|
||||
? "A library entry with identical content is selected above — re-linking reconnects them."
|
||||
: "Pick a match above to re-link, or save it as a new entry." }}
|
||||
</template>
|
||||
<template v-else>
|
||||
This module isn’t linked to any library entry under its recorded id, and
|
||||
nothing in the library matches its content or name. “Update existing” is
|
||||
unavailable — “Save as new entry” adds it to the library.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="wp-ptl-error" data-test="ptl-error">
|
||||
@@ -510,7 +720,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
font-family: inherit;
|
||||
}
|
||||
.wp-ptl-input:focus { outline: 1px solid var(--wp-accent-500, #8b5cf6); }
|
||||
.wp-ptl-input--multi { resize: vertical; min-height: 48px; }
|
||||
.wp-ptl-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 48px; }
|
||||
.wp-ptl-payload {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
@@ -558,6 +768,66 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 32%, transparent);
|
||||
color: var(--wp-danger, #ef4444);
|
||||
}
|
||||
/* Informational tone for the "pick a match" case — a re-linkable module is a
|
||||
routine state, not an error, and painting it red pushed users toward the
|
||||
duplicate-creating escape hatch. */
|
||||
.wp-ptl-note--info {
|
||||
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
|
||||
color: var(--wp-text, #e7e7ee);
|
||||
}
|
||||
.wp-ptl-relink {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 8%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wp-ptl-relink__title { font-size: 12.5px; font-weight: 600; color: var(--wp-text, #e7e7ee); }
|
||||
.wp-ptl-relink__hint { margin: 0; font-size: 11.5px; color: var(--wp-text-muted, #a1a1ad); }
|
||||
.wp-ptl-relink__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.wp-ptl-relink__cand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-ptl-relink__cand:hover { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 16%, transparent); }
|
||||
.wp-ptl-relink__cand.is-picked { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 28%, transparent); }
|
||||
.wp-ptl-relink__name { font-size: 12px; color: var(--wp-text, #e7e7ee); }
|
||||
.wp-ptl-relink__uuid { font-family: var(--wp-font-mono, monospace); font-size: 10px; color: var(--wp-text-dim, #6e6e7c); }
|
||||
.wp-ptl-relink__tag {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wp-ptl-relink__tag.is-identical {
|
||||
color: var(--wp-green, #6ad28b);
|
||||
background: color-mix(in oklab, var(--wp-green, #6ad28b) 16%, transparent);
|
||||
}
|
||||
.wp-ptl-relink__tag.is-diff {
|
||||
color: var(--wp-warn, #facc15);
|
||||
background: color-mix(in oklab, var(--wp-warn, #facc15) 16%, transparent);
|
||||
}
|
||||
.wp-ptl-relink .wp-ptl-btn--primary { align-self: flex-start; }
|
||||
.wp-ptl-error {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -75,6 +75,14 @@ const frameColor = computed(() =>
|
||||
props.color && props.color.length ? props.color : "var(--wp-bundle-default)",
|
||||
);
|
||||
|
||||
/** Tooltip for the MISSING badge. Names the two recoveries in the order the
|
||||
* user should try them — Re-link reconnects to an existing entry, saving a
|
||||
* new one is the fallback. The old copy only offered the fallback, which is
|
||||
* what produced duplicate library entries. */
|
||||
const missingTitle =
|
||||
"No live library entry for this bundle under its recorded id. "
|
||||
+ "Right-click → Push to library: re-link it to a matching entry, or save it as a new one.";
|
||||
|
||||
const summary = computed(() => {
|
||||
const word = props.childCount === 1 ? "mod" : "mods";
|
||||
const parts = [`${props.childCount} ${word}`];
|
||||
@@ -165,16 +173,21 @@ const summary = computed(() => {
|
||||
its own dot — re-stacking them at the bundle level would be
|
||||
visual noise. -->
|
||||
<span v-if="libraryMissing || libraryDrifted || snapshotModified" class="wp-mod-dots">
|
||||
<!-- Visual hierarchy: MISSING (library deleted) is the most severe
|
||||
<!-- Visual hierarchy: MISSING (no live library entry) is the most severe
|
||||
and shows first; LIBRARY UPDATED (drift) dominates next; MOD
|
||||
sits to its right. Multiple can show simultaneously — they
|
||||
represent independent axes. -->
|
||||
represent independent axes.
|
||||
Copy states the observable fact rather than asserting the entry was
|
||||
DELETED: an id absent from the library reads identically whether it
|
||||
was deleted, re-imported under a new id, or arrived in a shared
|
||||
workflow — and Push to library now offers Re-link for the last two,
|
||||
which "deleted" steered users away from. -->
|
||||
<template v-if="libraryMissing">
|
||||
<span class="wp-mod-dot wp-mod-dot--missing"
|
||||
title="The bundle's library entry has been deleted. Right-click → Push to library to re-add as a new entry."
|
||||
:title="missingTitle"
|
||||
aria-hidden="true"></span>
|
||||
<span class="wp-mod-badge wp-mod-badge--missing"
|
||||
title="The bundle's library entry has been deleted. Right-click → Push to library to re-add as a new entry.">missing</span>
|
||||
:title="missingTitle">missing</span>
|
||||
</template>
|
||||
<template v-if="libraryDrifted">
|
||||
<span class="wp-mod-dot wp-mod-dot--drift"
|
||||
@@ -399,10 +412,19 @@ const summary = computed(() => {
|
||||
color: var(--wp-warn);
|
||||
background: color-mix(in srgb, var(--wp-warn) 7%, transparent);
|
||||
}
|
||||
/* Child-count / drift summary. Was 10px in --wp-text-dim (#666 on the dark
|
||||
* theme), which sat right at the edge of legibility against the bundle frame
|
||||
* tint — the one piece of information telling you how big a collapsed bundle
|
||||
* is. Bumped to the muted token at 11px and given a faint pill so it reads as
|
||||
* a value rather than a watermark. */
|
||||
.wp-bundle-summary {
|
||||
font: 500 10px/1.3 var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
font: 600 11px/1.3 var(--wp-font-sans);
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
flex-shrink: 0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--wp-text-muted, var(--wp-text2)) 12%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Bundle action button — base size + shape from shared
|
||||
* .wp-btn--icon-sm. Bundle-specific tweak: bg + border tinted with
|
||||
|
||||
@@ -1,4 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { bundleContentKey } from "./bundle-fingerprint";
|
||||
|
||||
describe("bundleContentKey", () => {
|
||||
it("agrees across two copies of the same library bundle", () => {
|
||||
// This is the point: `computeBundleFingerprint` folds in per-instance
|
||||
// `_uid` / meta / instance, so a fresh insert never matches the library
|
||||
// row it came from. This key keeps only what survives an insert.
|
||||
const libraryChildren = [
|
||||
{ type: "wildcard", payload_hash: "H1", id: "aaa" },
|
||||
{ type: "constraint", payload_hash: "H2", id: "bbb" },
|
||||
];
|
||||
const workflowChildren = [
|
||||
{ type: "wildcard", payload_hash: "H1", id: "zzz", _uid: "u1", meta: { name: "renamed" } },
|
||||
{ type: "constraint", payload_hash: "H2", id: "yyy", _uid: "u2" },
|
||||
];
|
||||
expect(bundleContentKey(workflowChildren)).toBe(bundleContentKey(libraryChildren));
|
||||
});
|
||||
|
||||
it("separates different children", () => {
|
||||
expect(bundleContentKey([{ type: "wildcard", payload_hash: "H1" }]))
|
||||
.not.toBe(bundleContentKey([{ type: "wildcard", payload_hash: "H2" }]));
|
||||
});
|
||||
|
||||
it("respects child ORDER — a bundle is an ordered range", () => {
|
||||
const a = [{ type: "wildcard", payload_hash: "H1" }, { type: "combine", payload_hash: "H2" }];
|
||||
const b = [{ type: "combine", payload_hash: "H2" }, { type: "wildcard", payload_hash: "H1" }];
|
||||
expect(bundleContentKey(a)).not.toBe(bundleContentKey(b));
|
||||
});
|
||||
|
||||
it("keeps a hash-less child in the shape rather than skipping it", () => {
|
||||
const withChild = [{ type: "wildcard", payload_hash: "H1" }, { type: "combine" }];
|
||||
const without = [{ type: "wildcard", payload_hash: "H1" }];
|
||||
expect(bundleContentKey(withChild)).not.toBe(bundleContentKey(without));
|
||||
});
|
||||
|
||||
it("distinguishes an empty bundle from a one-child bundle", () => {
|
||||
expect(bundleContentKey([])).not.toBe(bundleContentKey([{ type: "wildcard" }]));
|
||||
});
|
||||
});
|
||||
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
|
||||
import {
|
||||
computeBundleFingerprint,
|
||||
|
||||
@@ -132,6 +132,31 @@ export function computeBundleFingerprint(
|
||||
return `${FINGERPRINT_VERSION}:${djb2(parts.join("\n"))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-library content key for a bundle: the ordered list of its children's
|
||||
* `(type, payload_hash)` pairs.
|
||||
*
|
||||
* Distinct from `computeBundleFingerprint`, which answers "have MY children
|
||||
* been edited since insert" and therefore folds in per-instance state — `_uid`,
|
||||
* `meta`, `instance`. That makes it useless for comparing a workflow bundle
|
||||
* against a LIBRARY bundle: a fresh insert mints new uids, so two copies of the
|
||||
* same library bundle never agree.
|
||||
*
|
||||
* This one deliberately keeps only what survives an insert, so a workflow
|
||||
* bundle and the library row it came from produce the same key. Order matters
|
||||
* (a bundle IS an ordered range) and children with no `payload_hash` contribute
|
||||
* an empty slot rather than being skipped, so a hash-less child still affects
|
||||
* the shape.
|
||||
*
|
||||
* Used by the re-link picker when a bundle carries no `inserted_at_hash`.
|
||||
*/
|
||||
export function bundleContentKey(
|
||||
children: ReadonlyArray<{ type?: string; payload_hash?: string }>,
|
||||
): string {
|
||||
const parts = children.map((c) => `${c.type ?? ""}|${c.payload_hash ?? ""}`);
|
||||
return `bk1:${djb2(parts.join("\n"))}`;
|
||||
}
|
||||
|
||||
/** True when the bundle's current children diverge from the snapshot
|
||||
* fingerprint stored on the instance. Returns FALSE when:
|
||||
* - no fingerprint stored (workflow predates MOD detection), OR
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { cascadeRestoreForBundle, rewriteChildId } from "./cascade-restore";
|
||||
import {
|
||||
bundleSnapshotModified,
|
||||
computeBundleFingerprint,
|
||||
} from "./bundle-fingerprint";
|
||||
import { api } from "../../../manager/api/client";
|
||||
import type { BundleRow, ModuleRow } from "../../../manager/api/types";
|
||||
import {
|
||||
emptyBundleInstance,
|
||||
type BundleInstance,
|
||||
type ModuleEntry,
|
||||
} from "../../../widgets/_shared";
|
||||
|
||||
/**
|
||||
* #2 regression: cascade-restore must remap references INSIDE a child's
|
||||
* payload (constraint source/target, @{} refs), not just the child's own id.
|
||||
* When Phase 1 re-creates a missing module under a fresh uuid, a sibling
|
||||
* constraint that points at the old uuid must be re-pointed too, or the
|
||||
* pushed bundle ships a dead source/target.
|
||||
*/
|
||||
describe("cascade-restore rewriteChildId — payload ref remap (#2)", () => {
|
||||
it("remaps a constraint child's source/target uuids to the restored module ids", () => {
|
||||
const moduleMap = new Map([
|
||||
["aaaaaaaa", "bbbbbbbb"],
|
||||
["cccccccc", "dddddddd"],
|
||||
]);
|
||||
const child = {
|
||||
id: "cn000001",
|
||||
type: "constraint",
|
||||
payload: { source_wildcard_id: "aaaaaaaa", target_wildcard_id: "cccccccc", matrix: {} },
|
||||
};
|
||||
const out = rewriteChildId(child, moduleMap, new Map());
|
||||
const p = out.payload as Record<string, unknown>;
|
||||
expect(p.source_wildcard_id).toBe("bbbbbbbb");
|
||||
expect(p.target_wildcard_id).toBe("dddddddd");
|
||||
});
|
||||
|
||||
it("remaps embedded @{uuid} refs in a child's payload (preserving #name)", () => {
|
||||
const moduleMap = new Map([["aabbccdd", "11223344"]]);
|
||||
const child = {
|
||||
id: "wc000001",
|
||||
type: "wildcard",
|
||||
payload: { options: [{ id: "o1", value: "see @{aabbccdd#color} here" }] },
|
||||
};
|
||||
const out = rewriteChildId(child, moduleMap, new Map());
|
||||
expect(JSON.stringify(out.payload)).toContain("@{11223344#color}");
|
||||
});
|
||||
|
||||
it("remaps refs found in a child's instance overrides too", () => {
|
||||
const moduleMap = new Map([["aaaaaaaa", "bbbbbbbb"]]);
|
||||
const child = {
|
||||
id: "cn000002",
|
||||
type: "constraint",
|
||||
payload: { source_wildcard_id: "aaaaaaaa" },
|
||||
instance: { target_select: { picks: [{ carrier_uid: "aaaaaaaa", option_id: "r1:0" }] } },
|
||||
};
|
||||
const out = rewriteChildId(child, moduleMap, new Map());
|
||||
expect(JSON.stringify(out.instance)).toContain("bbbbbbbb");
|
||||
expect(JSON.stringify(out.instance)).not.toContain("aaaaaaaa");
|
||||
});
|
||||
|
||||
it("leaves refs to non-restored (external) uuids untouched", () => {
|
||||
const child = {
|
||||
id: "cn000003",
|
||||
type: "constraint",
|
||||
payload: { source_wildcard_id: "eeeeeeee", target_wildcard_id: "ffffffff" },
|
||||
};
|
||||
const out = rewriteChildId(child, new Map([["aaaaaaaa", "bbbbbbbb"]]), new Map());
|
||||
const p = out.payload as Record<string, unknown>;
|
||||
expect(p.source_wildcard_id).toBe("eeeeeeee");
|
||||
expect(p.target_wildcard_id).toBe("ffffffff");
|
||||
});
|
||||
|
||||
it("still rewrites the child's own top-level id (module + bundle)", () => {
|
||||
const moduleMap = new Map([["aaaaaaaa", "bbbbbbbb"]]);
|
||||
const innerBundleMap = new Map([["bndl1111", "bndl2222"]]);
|
||||
const mod = rewriteChildId({ id: "aaaaaaaa", type: "wildcard", payload: {} }, moduleMap, innerBundleMap);
|
||||
expect(mod.id).toBe("bbbbbbbb");
|
||||
const bun = rewriteChildId({ id: "bndl1111", type: "bundle" }, moduleMap, innerBundleMap);
|
||||
expect(bun.id).toBe("bndl2222");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #2 end-to-end: the headline claim is a CHAIN — push → cascade restore
|
||||
* re-creates the missing wildcard under a fresh uuid → a sibling
|
||||
* constraint pointing at the dead uuid is repointed in the children the
|
||||
* outer ships. The pure `rewriteChildId` tests above lock the rewrite
|
||||
* primitive; this exercises `cascadeRestoreForBundle` itself (Phase 1
|
||||
* POST → Phase 3 children rebuild → Phase 4 workflow rebind) with the
|
||||
* module POST mocked. Also re-asserts the #2 invariant inside the live
|
||||
* flow: an embedded `@{uuid}` ref in the constraint follows the repoint
|
||||
* too, not just the bare source/target fields.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — end-to-end ref repoint (#2)", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mkModule(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "h",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("restores a missing wildcard and repoints its sibling constraint's source + embedded @{} ref", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ id: "beef0001" }),
|
||||
text: async () => "",
|
||||
}) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// deadbeef = the wildcard whose library row was deleted upstream.
|
||||
const wildcard = mkModule({
|
||||
id: "deadbeef",
|
||||
type: "wildcard",
|
||||
_uid: "u-w",
|
||||
meta: { name: "Palette" },
|
||||
payload: { options: [] },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
// A sibling constraint sources from deadbeef + carries an embedded
|
||||
// @{deadbeef#palette} ref inside an exception value. facade00 is an
|
||||
// EXTERNAL target (not restored) — it must survive untouched.
|
||||
const constraint = mkModule({
|
||||
id: "ca000001",
|
||||
type: "constraint",
|
||||
_uid: "u-c",
|
||||
meta: { name: "Pairing" },
|
||||
payload: {
|
||||
source_wildcard_id: "deadbeef",
|
||||
target_wildcard_id: "facade00",
|
||||
matrix: {},
|
||||
exceptions: [{ source_value: "see @{deadbeef#palette}", target_value: "sky" }],
|
||||
},
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("bndllib0"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 1,
|
||||
name: "Kit",
|
||||
};
|
||||
const modules = [wildcard, constraint];
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles: [outer],
|
||||
isModuleMissing: (m) => m.id === "deadbeef",
|
||||
isBundleMissing: () => false,
|
||||
});
|
||||
|
||||
// Only the wildcard was missing → exactly one module POST.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.restoredModuleCount).toBe(1);
|
||||
|
||||
const cn = result.rewrittenChildren.find((c) => c.type === "constraint");
|
||||
const p = cn?.payload as Record<string, unknown>;
|
||||
expect(p.source_wildcard_id).toBe("beef0001"); // repointed to the restored id
|
||||
expect(p.target_wildcard_id).toBe("facade00"); // external ref left alone
|
||||
// #2 invariant re-asserted in the live flow: embedded @{} ref follows too,
|
||||
// preserving its #name segment.
|
||||
expect(JSON.stringify(cn?.payload)).toContain("@{beef0001#palette}");
|
||||
expect(JSON.stringify(cn?.payload)).not.toContain("deadbeef");
|
||||
|
||||
// Workflow rebind swaps the dead id while preserving the per-instance _uid.
|
||||
const w = result.newModules.find((m) => m._uid === "u-w");
|
||||
expect(w?.id).toBe("beef0001");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* QA bug (2026-06-12): bundle push-with-cascade reattached the constraint's
|
||||
* TARGET but not its SOURCE — in the WORKFLOW. Root cause: Phase 4's
|
||||
* `newModules` swapped each module's own `id` but never rewrote payload refs,
|
||||
* so a restored constraint's source/target stayed pointed at the dead uuids
|
||||
* on the canvas (the pushed library children, Phase 3, were already correct).
|
||||
* This locks the workflow rebind: a restored module's INTERNAL refs follow
|
||||
* the restore too, both endpoints.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — Phase 4 workflow rebind rewrites payload refs", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mkMod(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "h",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("repoints a restored constraint's source AND target in newModules, not just rewrittenChildren", async () => {
|
||||
// Distinct id per POST, in outer-range order: subject, mood, constraint.
|
||||
const minted = ["beef0001", "beef0002", "ca110011"];
|
||||
let n = 0;
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
// `headers` present because Phase 1 Pass 2's corrective PUT for the
|
||||
// restored constraint routes through `api.modules.update` →
|
||||
// `request()` → `checkStartupId(resp)`, which reads `resp.headers`.
|
||||
headers: { get: () => null },
|
||||
json: async () => ({ id: minted[n++] }),
|
||||
text: async () => "",
|
||||
}) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const subject = mkMod({
|
||||
id: "deadbeef",
|
||||
type: "wildcard",
|
||||
_uid: "u-s",
|
||||
meta: { name: "subject" },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const mood = mkMod({
|
||||
id: "facade00",
|
||||
type: "wildcard",
|
||||
_uid: "u-m",
|
||||
meta: { name: "mood" },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const constraint = mkMod({
|
||||
id: "ca000001",
|
||||
type: "constraint",
|
||||
_uid: "u-c",
|
||||
meta: { name: "pairing" },
|
||||
payload: { source_wildcard_id: "deadbeef", target_wildcard_id: "facade00", matrix: {} },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("bndllib1"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 2,
|
||||
name: "Kit",
|
||||
};
|
||||
const modules = [subject, mood, constraint];
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles: [outer],
|
||||
isModuleMissing: (m) => ["deadbeef", "facade00", "ca000001"].includes(m.id),
|
||||
isBundleMissing: () => false,
|
||||
});
|
||||
|
||||
// All three restored → ids swapped in the workflow rebind.
|
||||
const s = result.newModules.find((m) => m._uid === "u-s");
|
||||
const t = result.newModules.find((m) => m._uid === "u-m");
|
||||
const c = result.newModules.find((m) => m._uid === "u-c");
|
||||
expect(s?.id).toBe("beef0001");
|
||||
expect(t?.id).toBe("beef0002");
|
||||
// The CANVAS constraint must repoint BOTH endpoints (the SRC-MISSING bug
|
||||
// was: only target followed the restore, source stayed dead).
|
||||
const p = c?.payload as Record<string, unknown>;
|
||||
expect(p.source_wildcard_id).toBe("beef0001");
|
||||
expect(p.target_wildcard_id).toBe("beef0002");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 1 third-output bug (forward-ref): each missing module is POSTed
|
||||
* RAW to mint a fresh library id (Pass 1). For a restored constraint, that
|
||||
* standalone library entry ships DANGLING source/target — they still point
|
||||
* at the OLD (now-deleted) uuids, because the constraint is POSTed BEFORE
|
||||
* its target wildcard mints a new id (moduleMap is incomplete mid-loop).
|
||||
* Phase 3 (pushed bundle children) + Phase 4 (workflow rebind) both already
|
||||
* rewrite refs; Phase 1's standalone library entries did not.
|
||||
*
|
||||
* The fix is a two-pass Phase 1: Pass 1 mints ids (raw POST); Pass 2 runs
|
||||
* AFTER the loop (moduleMap COMPLETE), walkRemaps each restored module's
|
||||
* payload and, when refs actually changed, corrects the freshly-created
|
||||
* library entry via `api.modules.update`. A pure wildcard with no internal
|
||||
* refs must NOT trigger a corrective PUT.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — Phase 1 Pass 2 corrects standalone library refs", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mkMod(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "h",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("repoints a forward-ref constraint's source + target via api.modules.update, and skips a ref-free wildcard", async () => {
|
||||
// Outer range order is FORWARD-REF: the constraint sits BEFORE its
|
||||
// target wildcard, so moduleMap is incomplete when the constraint is
|
||||
// POSTed — only a post-loop Pass 2 can repoint target_wildcard_id.
|
||||
// [ subject (deadbeef), constraint (ca000001 → src deadbeef / tgt facade00), mood (facade00) ]
|
||||
// POSTs mint, in order: beef0001 (subject), ca110011 (constraint), beef0002 (mood).
|
||||
const minted = ["beef0001", "ca110011", "beef0002"];
|
||||
let n = 0;
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ id: minted[n++] }),
|
||||
text: async () => "",
|
||||
}) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// Capture the corrective PUT(s) without hitting the network.
|
||||
const updateSpy = vi
|
||||
.spyOn(api.modules, "update")
|
||||
.mockImplementation(async (id: string) => ({ id }) as unknown as ModuleRow);
|
||||
|
||||
const subject = mkMod({
|
||||
id: "deadbeef",
|
||||
type: "wildcard",
|
||||
_uid: "u-s",
|
||||
meta: { name: "subject" },
|
||||
payload: { options: [] }, // no internal refs → must NOT trigger a PUT
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const constraint = mkMod({
|
||||
id: "ca000001",
|
||||
type: "constraint",
|
||||
_uid: "u-c",
|
||||
meta: { name: "pairing" },
|
||||
payload: {
|
||||
source_wildcard_id: "deadbeef",
|
||||
target_wildcard_id: "facade00",
|
||||
matrix: {},
|
||||
},
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const mood = mkMod({
|
||||
id: "facade00",
|
||||
type: "wildcard",
|
||||
_uid: "u-m",
|
||||
meta: { name: "mood" },
|
||||
payload: { options: [] }, // no internal refs → must NOT trigger a PUT
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("bndllib2"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 2,
|
||||
name: "Kit",
|
||||
};
|
||||
const modules = [subject, constraint, mood];
|
||||
|
||||
await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles: [outer],
|
||||
isModuleMissing: (m) => ["deadbeef", "ca000001", "facade00"].includes(m.id),
|
||||
isBundleMissing: () => false,
|
||||
});
|
||||
|
||||
// Exactly ONE corrective PUT — only the constraint carried sibling refs.
|
||||
expect(updateSpy).toHaveBeenCalledTimes(1);
|
||||
const [calledId, calledBody] = updateSpy.mock.calls[0];
|
||||
// Corrective PUT targets the constraint's freshly-minted id.
|
||||
expect(calledId).toBe("ca110011");
|
||||
const p = (calledBody as { payload?: Record<string, unknown> }).payload;
|
||||
expect(p).toBeDefined();
|
||||
// BOTH endpoints repointed despite the forward-ref POST order.
|
||||
expect(p?.source_wildcard_id).toBe("beef0001"); // subject's new id
|
||||
expect(p?.target_wildcard_id).toBe("beef0002"); // mood's new id (forward ref!)
|
||||
|
||||
// The two ref-free wildcards (beef0001, beef0002) were NOT corrected.
|
||||
const correctedIds = updateSpy.mock.calls.map((c) => c[0]);
|
||||
expect(correctedIds).not.toContain("beef0001");
|
||||
expect(correctedIds).not.toContain("beef0002");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* QA bug (2026-06-12): after a cascade restore the canvas flashed DRIFT on a
|
||||
* restored constraint. Root cause — Phase 4 rebound the workflow row's id +
|
||||
* payload (refs repointed) but kept the STALE frozen-snapshot payload_hash,
|
||||
* which no longer matched the corrected library entry's hash (a ref-free
|
||||
* wildcard didn't drift because its payload, hence hash, was unchanged).
|
||||
* Phase 1 now captures the library hash (POST + corrective update) and Phase 4
|
||||
* stamps it, mirroring the bundle inserted_at_hash sync.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — Phase 4 syncs payload_hash to the library entry", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mkMod(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "stale-frozen-hash",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("stamps the corrected library hash on a restored constraint (no DRIFT), and the POST hash on a ref-free wildcard", async () => {
|
||||
// POST mints {id, payload_hash} per restored module, in range order:
|
||||
// subject (deadbeef) → beef0001 / "wh-post" (ref-free)
|
||||
// constraint (ca000001, src deadbeef) → ca110011 / "cn-post"
|
||||
const minted = [
|
||||
{ id: "beef0001", payload_hash: "wh-post" },
|
||||
{ id: "ca110011", payload_hash: "cn-post" },
|
||||
];
|
||||
let n = 0;
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => minted[n++],
|
||||
text: async () => "",
|
||||
}) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// The corrective PUT returns the CORRECTED hash (the constraint's payload moved).
|
||||
vi.spyOn(api.modules, "update").mockImplementation(
|
||||
async (id: string) => ({ id, payload_hash: "cn-corrected" }) as unknown as ModuleRow,
|
||||
);
|
||||
|
||||
const subject = mkMod({
|
||||
id: "deadbeef",
|
||||
type: "wildcard",
|
||||
_uid: "u-s",
|
||||
meta: { name: "subject" },
|
||||
payload: { options: [] }, // ref-free → no corrective PUT → POST hash
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const constraint = mkMod({
|
||||
id: "ca000001",
|
||||
type: "constraint",
|
||||
_uid: "u-c",
|
||||
meta: { name: "pairing" },
|
||||
payload: { source_wildcard_id: "deadbeef", target_wildcard_id: "facade00", matrix: {} },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("bndllib3"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 1,
|
||||
name: "Kit",
|
||||
};
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules: [subject, constraint],
|
||||
bundles: [outer],
|
||||
isModuleMissing: (m) => ["deadbeef", "ca000001"].includes(m.id),
|
||||
isBundleMissing: () => false,
|
||||
});
|
||||
|
||||
const s = result.newModules.find((m) => m._uid === "u-s");
|
||||
const c = result.newModules.find((m) => m._uid === "u-c");
|
||||
// Ref-free wildcard → its POST hash.
|
||||
expect(s?.payload_hash).toBe("wh-post");
|
||||
// Corrected constraint → the corrective-update hash, NOT the stale frozen
|
||||
// hash (which is what produced the DRIFT badge).
|
||||
expect(c?.payload_hash).toBe("cn-corrected");
|
||||
expect(c?.payload_hash).not.toBe("stale-frozen-hash");
|
||||
|
||||
// Gap #1 (holistic audit 2026-06-13): the PUSHED bundle children (Phase 3)
|
||||
// must carry the SAME live hashes. `toChildSnapshot` froze the stale
|
||||
// pre-cascade hash, so without the rewriteChildId hash-stamp the pushed
|
||||
// bundle ships a child whose payload was corrected but whose payload_hash
|
||||
// is stale → RE-INSERTING the bundle shows spurious DRIFT against the live
|
||||
// library entry.
|
||||
const childWild = result.rewrittenChildren.find((c2) => c2.type === "wildcard");
|
||||
const childCon = result.rewrittenChildren.find((c2) => c2.type === "constraint");
|
||||
expect(childWild?.payload_hash).toBe("wh-post");
|
||||
expect(childCon?.payload_hash).toBe("cn-corrected");
|
||||
expect(childCon?.payload_hash).not.toBe("stale-frozen-hash");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* QA bug (2026-06-15): pushing an outer bundle with the cascade "Restore N
|
||||
* missing references first" option re-creates a missing INNER bundle, but the
|
||||
* inner then flashes a false MODIFIED drift badge — even though it was just
|
||||
* freshly created and matches the library. Root cause — Phase 4's `newBundles`
|
||||
* rebinds each restored inner's `library_id` + `inserted_at_hash` but kept the
|
||||
* STALE `snapshot_fingerprint` captured before the restore. The drift detector
|
||||
* `bundleSnapshotModified` compares the LIVE fingerprint against that stale
|
||||
* stored value → they differ → false MODIFIED. (`onBundlePushSaved` only
|
||||
* recomputes the OUTER's fingerprint, never the inner ones.)
|
||||
*
|
||||
* The fix is the symmetric sibling of the inserted_at_hash sync: Phase 4 also
|
||||
* reconciles each RESTORED inner's `snapshot_fingerprint` to
|
||||
* `computeBundleFingerprint(rebound, newModules)` so the freshly-restored
|
||||
* content matches its stored baseline (no drift). Only restored inners are
|
||||
* touched — a genuinely-modified, non-restored bundle keeps its own
|
||||
* fingerprint so real drift still surfaces.
|
||||
*/
|
||||
describe("cascadeRestoreForBundle — Phase 4 reconciles restored inner-bundle fingerprint", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mkMod(
|
||||
over: Partial<ModuleEntry> & { bundle_origin?: string },
|
||||
): ModuleEntry & { bundle_origin?: string } {
|
||||
return {
|
||||
id: "00000000",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
meta: { name: "" },
|
||||
entries: [],
|
||||
payload: {},
|
||||
instance: {},
|
||||
payload_hash: "h",
|
||||
_uid: "u",
|
||||
...over,
|
||||
} as ModuleEntry & { bundle_origin?: string };
|
||||
}
|
||||
|
||||
it("clears false drift on a restored inner bundle carrying a stale snapshot_fingerprint", async () => {
|
||||
// No modules are missing → fetch (module POST) is never called; only the
|
||||
// inner bundle's library row was deleted upstream.
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({ ok: true, json: async () => ({}), text: async () => "" }) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
// The restored inner bundle's fresh library entry.
|
||||
const createSpy = vi
|
||||
.spyOn(api.bundles, "create")
|
||||
.mockResolvedValue({ id: "innernew", payload_hash: "inner-fresh-hash" } as BundleRow);
|
||||
|
||||
// Outer (range 0-2): a top-level wildcard at idx 0, then an inner bundle
|
||||
// covering idx 1-2 (two wildcards owned by the inner).
|
||||
const topWild = mkMod({
|
||||
id: "top00001",
|
||||
type: "wildcard",
|
||||
_uid: "u-top",
|
||||
meta: { name: "top" },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const innerA = mkMod({
|
||||
id: "inna0001",
|
||||
type: "wildcard",
|
||||
_uid: "u-ia",
|
||||
meta: { name: "innerA" },
|
||||
payload_hash: "ha",
|
||||
bundle_origin: "inner-uid",
|
||||
});
|
||||
const innerB = mkMod({
|
||||
id: "innb0001",
|
||||
type: "wildcard",
|
||||
_uid: "u-ib",
|
||||
meta: { name: "innerB" },
|
||||
payload_hash: "hb",
|
||||
bundle_origin: "inner-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("outerlib"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 2,
|
||||
name: "Kit",
|
||||
};
|
||||
// Missing inner bundle carrying a STALE fingerprint that won't match the
|
||||
// live content — this is what produces the false MODIFIED badge today.
|
||||
const inner: BundleInstance = {
|
||||
...emptyBundleInstance("innerlibOLD"),
|
||||
_uid: "inner-uid",
|
||||
parent_uid: "outer-uid",
|
||||
start_idx: 1,
|
||||
end_idx: 2,
|
||||
name: "Inner",
|
||||
snapshot_fingerprint: "v2:stale",
|
||||
};
|
||||
const modules = [topWild, innerA, innerB];
|
||||
const bundles = [outer, inner];
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles,
|
||||
isModuleMissing: () => false,
|
||||
isBundleMissing: (b) => b._uid === "inner-uid",
|
||||
});
|
||||
|
||||
// The inner bundle was restored (Phase 2 POST) and rebound (Phase 4).
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result.restoredBundleCount).toBe(1);
|
||||
|
||||
const reboundInner = result.newBundles.find((b) => b._uid === "inner-uid")!;
|
||||
// Sanity: rebind swapped the dead library_id + synced inserted_at_hash.
|
||||
expect(reboundInner.library_id).toBe("innernew");
|
||||
expect(reboundInner.inserted_at_hash).toBe("inner-fresh-hash");
|
||||
|
||||
// The headline fix: the rebound inner's snapshot_fingerprint matches the
|
||||
// freshly-restored content → NO false drift. (Stale "v2:stale" would fail.)
|
||||
expect(reboundInner.snapshot_fingerprint).toBe(
|
||||
computeBundleFingerprint(reboundInner, result.newModules),
|
||||
);
|
||||
expect(bundleSnapshotModified(reboundInner, result.newModules)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT touch a non-restored, genuinely-modified bundle's fingerprint", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
({ ok: true, json: async () => ({}), text: async () => "" }) as unknown as Response,
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const createSpy = vi
|
||||
.spyOn(api.bundles, "create")
|
||||
.mockResolvedValue({ id: "innernew", payload_hash: "inner-fresh-hash" } as BundleRow);
|
||||
|
||||
// Outer (range 0-3): top-level wildcard, a MISSING inner (idx 1), and a
|
||||
// SIBLING inner (idx 2-3) that is NOT missing but genuinely drifted.
|
||||
const topWild = mkMod({
|
||||
id: "top00001",
|
||||
type: "wildcard",
|
||||
_uid: "u-top",
|
||||
meta: { name: "top" },
|
||||
bundle_origin: "outer-uid",
|
||||
});
|
||||
const missingChild = mkMod({
|
||||
id: "miss0001",
|
||||
type: "wildcard",
|
||||
_uid: "u-miss",
|
||||
meta: { name: "missingChild" },
|
||||
payload_hash: "hm",
|
||||
bundle_origin: "missing-uid",
|
||||
});
|
||||
const siblingA = mkMod({
|
||||
id: "sibl0001",
|
||||
type: "wildcard",
|
||||
_uid: "u-sa",
|
||||
meta: { name: "siblingA" },
|
||||
payload_hash: "hs",
|
||||
bundle_origin: "sibling-uid",
|
||||
});
|
||||
const siblingB = mkMod({
|
||||
id: "sibl0002",
|
||||
type: "wildcard",
|
||||
_uid: "u-sb",
|
||||
meta: { name: "siblingB" },
|
||||
payload_hash: "hs2",
|
||||
bundle_origin: "sibling-uid",
|
||||
});
|
||||
const outer: BundleInstance = {
|
||||
...emptyBundleInstance("outerlib"),
|
||||
_uid: "outer-uid",
|
||||
start_idx: 0,
|
||||
end_idx: 3,
|
||||
name: "Kit",
|
||||
};
|
||||
const missingInner: BundleInstance = {
|
||||
...emptyBundleInstance("missinglibOLD"),
|
||||
_uid: "missing-uid",
|
||||
parent_uid: "outer-uid",
|
||||
start_idx: 1,
|
||||
end_idx: 1,
|
||||
name: "Missing",
|
||||
snapshot_fingerprint: "v2:stale",
|
||||
};
|
||||
// A genuine pre-existing drift baseline: the sibling's stored fingerprint
|
||||
// intentionally does NOT match its live content. Cascade must NOT erase it.
|
||||
const driftedSibling: BundleInstance = {
|
||||
...emptyBundleInstance("siblinglib"),
|
||||
_uid: "sibling-uid",
|
||||
parent_uid: "outer-uid",
|
||||
start_idx: 2,
|
||||
end_idx: 3,
|
||||
name: "Sibling",
|
||||
snapshot_fingerprint: "v2:genuinedrift",
|
||||
};
|
||||
const modules = [topWild, missingChild, siblingA, siblingB];
|
||||
const bundles = [outer, missingInner, driftedSibling];
|
||||
|
||||
const result = await cascadeRestoreForBundle({
|
||||
outer,
|
||||
modules,
|
||||
bundles,
|
||||
isModuleMissing: () => false,
|
||||
isBundleMissing: (b) => b._uid === "missing-uid",
|
||||
});
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The non-restored sibling keeps its stored fingerprint untouched → its
|
||||
// genuine drift still surfaces. Blanket-recompute would have erased it.
|
||||
const reboundSibling = result.newBundles.find((b) => b._uid === "sibling-uid")!;
|
||||
expect(reboundSibling.snapshot_fingerprint).toBe("v2:genuinedrift");
|
||||
expect(bundleSnapshotModified(reboundSibling, result.newModules)).toBe(true);
|
||||
|
||||
// The restored inner is still clean (fix applies only to it).
|
||||
const reboundMissing = result.newBundles.find((b) => b._uid === "missing-uid")!;
|
||||
expect(bundleSnapshotModified(reboundMissing, result.newModules)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
buildLibraryChildrenWithIntegrity,
|
||||
toChildSnapshot,
|
||||
} from "./save";
|
||||
import { computeBundleFingerprint } from "./bundle-fingerprint";
|
||||
import { walkRemap } from "./uuid-remap";
|
||||
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
|
||||
|
||||
export interface CascadeRestoreInputs {
|
||||
@@ -91,7 +93,28 @@ export async function cascadeRestoreForBundle(
|
||||
// because inner-bundle children are still inside the outer's range —
|
||||
// they need their uuids in the moduleMap before Phase 2 rebuilds the
|
||||
// inner's children list.
|
||||
//
|
||||
// Two passes:
|
||||
// Pass 1 — POST each missing module RAW to MINT a fresh id. The raw
|
||||
// payload is just a seed; it may carry refs to SIBLING restored
|
||||
// modules that haven't minted their new ids yet (forward refs),
|
||||
// so the standalone library entry can ship dangling source/target
|
||||
// / `@{}` refs at this point.
|
||||
// Pass 2 — runs AFTER the loop, when `moduleMap` is COMPLETE. For
|
||||
// each restored module it walkRemaps the payload against the full
|
||||
// map; if any ref actually changed, it corrects the freshly-created
|
||||
// library entry via `api.modules.update`. This is the same rewrite
|
||||
// Phase 3 (pushed children) and Phase 4 (workflow rebind) apply —
|
||||
// the standalone library entries are the third output that needs it.
|
||||
// Ref-free modules (most wildcards) don't differ → no update.
|
||||
const moduleMap = new Map<string, string>();
|
||||
// Library payload_hash per restored module (keyed by OLD id). Phase 4 stamps
|
||||
// it on the workflow row so the canvas doesn't flash DRIFT against the
|
||||
// freshly-created/corrected library entry — mirror of the bundle
|
||||
// inserted_at_hash sync below. A corrected constraint is the case that bites:
|
||||
// its stale frozen-snapshot hash != the new entry's hash (its payload moved).
|
||||
const moduleHashMap = new Map<string, string>();
|
||||
const restored: { newId: string; module: ModuleEntry }[] = [];
|
||||
for (let i = outer.start_idx; i <= outer.end_idx; i++) {
|
||||
const m = modules[i];
|
||||
if (!m) continue;
|
||||
@@ -110,9 +133,32 @@ export async function cascadeRestoreForBundle(
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Module restore failed for "${baseName}": HTTP ${res.status} ${text}`);
|
||||
}
|
||||
const body = (await res.json()) as { id?: string };
|
||||
const body = (await res.json()) as { id?: string; payload_hash?: string };
|
||||
if (!body.id) throw new Error(`Module restore for "${baseName}" returned no id`);
|
||||
moduleMap.set(m.id, body.id);
|
||||
if (body.payload_hash) moduleHashMap.set(m.id, body.payload_hash);
|
||||
restored.push({ newId: body.id, module: m });
|
||||
}
|
||||
|
||||
// Pass 2: correct standalone library entries whose payload referenced a
|
||||
// sibling restored module. `moduleMap` is complete now, so forward refs
|
||||
// (a constraint POSTed before its target wildcard minted its id) resolve.
|
||||
const restoreTable: Record<string, string> = {};
|
||||
for (const [k, v] of moduleMap) restoreTable[k] = v;
|
||||
if (Object.keys(restoreTable).length > 0) {
|
||||
for (const { newId, module: m } of restored) {
|
||||
const original = m.payload ?? {};
|
||||
const rewrittenPayload = walkRemap(original, restoreTable) as Record<string, unknown>;
|
||||
// Only PUT when a ref actually moved — ref-free modules are unchanged.
|
||||
if (JSON.stringify(rewrittenPayload) === JSON.stringify(original)) continue;
|
||||
const updated = await api.modules.update(newId, {
|
||||
name: pickModuleName(m),
|
||||
payload: rewrittenPayload,
|
||||
});
|
||||
// The corrected payload changes the hash — capture it so Phase 4 stamps
|
||||
// the workflow row with the LIVE library hash (overrides the POST hash).
|
||||
moduleHashMap.set(m.id, updated.payload_hash);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: restore missing inner bundles ──────────────────────────
|
||||
@@ -130,7 +176,9 @@ export async function cascadeRestoreForBundle(
|
||||
if (inner.parent_uid !== outer._uid) continue;
|
||||
if (!isBundleMissing(inner)) continue;
|
||||
const integrity = buildLibraryChildrenWithIntegrity(inner, modules, bundles);
|
||||
const rewritten = integrity.children.map((c) => rewriteChildId(c, moduleMap, innerBundleMap));
|
||||
const rewritten = integrity.children.map((c) =>
|
||||
rewriteChildId(c, moduleMap, innerBundleMap, moduleHashMap),
|
||||
);
|
||||
const created = await api.bundles.create({
|
||||
name: inner.name || "bundle",
|
||||
color: inner.color ?? null,
|
||||
@@ -143,7 +191,7 @@ export async function cascadeRestoreForBundle(
|
||||
// ── Phase 3: build the outer's children with both rewrites applied ──
|
||||
const outerIntegrity = buildLibraryChildrenWithIntegrity(outer, modules, bundles);
|
||||
const rewrittenChildren = outerIntegrity.children.map((c) =>
|
||||
rewriteChildId(c, moduleMap, innerBundleMap),
|
||||
rewriteChildId(c, moduleMap, innerBundleMap, moduleHashMap),
|
||||
);
|
||||
|
||||
// ── Phase 4: rebind workflow state so MISSING badges clear ──────────
|
||||
@@ -152,18 +200,55 @@ export async function cascadeRestoreForBundle(
|
||||
// since-deleted library entry at insert time) wouldn't match the new
|
||||
// entry's hash and the BundleHeader would render LIBRARY UPDATED
|
||||
// until the user manually reset.
|
||||
const newModules = modules.map((m) =>
|
||||
moduleMap.has(m.id) ? { ...m, id: moduleMap.get(m.id)! } : m,
|
||||
);
|
||||
// Rewrite table shared with Phase 3's child rewrite. The workflow modules
|
||||
// need their INTERNAL refs repointed too — a restored constraint's
|
||||
// source/target + any embedded `@{}` ref — not just their own `id`.
|
||||
// Without this, after a cascade restore the canvas keeps a SRC/TGT-MISSING
|
||||
// constraint even though the pushed library entry (rewrittenChildren) is
|
||||
// correct: Phase 4 swapped ids but left payloads pointing at the dead uuids.
|
||||
const refTable: Record<string, string> = {};
|
||||
for (const [k, v] of moduleMap) refTable[k] = v;
|
||||
for (const [k, v] of innerBundleMap) refTable[k] = v;
|
||||
const hasRefs = Object.keys(refTable).length > 0;
|
||||
const newModules = modules.map((m) => {
|
||||
let next = moduleMap.has(m.id) ? { ...m, id: moduleMap.get(m.id)! } : m;
|
||||
if (hasRefs) {
|
||||
if (next.payload && typeof next.payload === "object") {
|
||||
next = { ...next, payload: walkRemap(next.payload, refTable) as ModuleEntry["payload"] };
|
||||
}
|
||||
if (next.instance && typeof next.instance === "object") {
|
||||
next = { ...next, instance: walkRemap(next.instance, refTable) as ModuleEntry["instance"] };
|
||||
}
|
||||
}
|
||||
// Sync payload_hash to the restored library entry so the canvas doesn't
|
||||
// flash DRIFT against it (mirror of newBundles' inserted_at_hash below).
|
||||
// moduleHashMap only holds restored modules, so a non-restored row keeps
|
||||
// its own hash.
|
||||
const newHash = moduleHashMap.get(m.id);
|
||||
if (newHash) next = { ...next, payload_hash: newHash };
|
||||
return next;
|
||||
});
|
||||
const newBundles = bundles.map((b) => {
|
||||
const next = innerBundleMap.get(b.library_id);
|
||||
if (!next) return b;
|
||||
const hash = innerHashMap.get(b.library_id);
|
||||
return {
|
||||
const rebound: BundleInstance = {
|
||||
...b,
|
||||
library_id: next,
|
||||
...(hash ? { inserted_at_hash: hash } : {}),
|
||||
};
|
||||
// Reconcile the restored inner's snapshot_fingerprint to its freshly-
|
||||
// restored content. Without this the rebound instance keeps the STALE
|
||||
// fingerprint captured before the restore, so `bundleSnapshotModified`
|
||||
// (live vs stored) flips true and the canvas flashes a false MODIFIED
|
||||
// badge on a bundle that was just re-created to match the library.
|
||||
// Mirror of the inserted_at_hash sync above. ONLY restored inners are
|
||||
// reconciled (this branch) — a non-restored bundle returns early and
|
||||
// keeps its own fingerprint, so genuine drift still surfaces.
|
||||
return {
|
||||
...rebound,
|
||||
snapshot_fingerprint: computeBundleFingerprint(rebound, newModules),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -175,24 +260,61 @@ export async function cascadeRestoreForBundle(
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply id rewrites to a single child entry. Bundle-typed children
|
||||
* look up in `innerBundleMap`; module-typed children look up in
|
||||
* `moduleMap`. Returns a NEW object only when a rewrite fires; otherwise
|
||||
* reuses the original reference to avoid churn for downstream diff
|
||||
* consumers. */
|
||||
/** Apply id rewrites to a single child entry. Two layers:
|
||||
*
|
||||
* 1. The child's OWN top-level `id` — bundle-typed children look up in
|
||||
* `innerBundleMap`; module-typed children in `moduleMap`.
|
||||
* 2. References INSIDE the child's `payload` + `instance` that point at
|
||||
* any restored module/inner-bundle — a constraint's
|
||||
* `source_wildcard_id` / `target_wildcard_id`, or an `@{uuid}` ref in
|
||||
* a wildcard/derivation value. Without (2), restoring a missing
|
||||
* wildcard re-pointed its own child id but left a sibling
|
||||
* constraint pointing at the dead uuid → the pushed bundle shipped a
|
||||
* broken source/target (#2). The deep rewrite reuses `walkRemap`.
|
||||
*
|
||||
* Returns a NEW object only when a rewrite fires; otherwise reuses the
|
||||
* original reference to avoid churn for downstream diff consumers. */
|
||||
function rewriteChildId(
|
||||
c: Record<string, unknown>,
|
||||
moduleMap: Map<string, string>,
|
||||
innerBundleMap: Map<string, string>,
|
||||
moduleHashMap?: ReadonlyMap<string, string>,
|
||||
): Record<string, unknown> {
|
||||
let next: Record<string, unknown> = c;
|
||||
|
||||
// (1) Own id.
|
||||
const id = typeof c.id === "string" ? c.id : "";
|
||||
if (!id) return c;
|
||||
if (c.type === "bundle") {
|
||||
const next = innerBundleMap.get(id);
|
||||
return next ? { ...c, id: next } : c;
|
||||
if (id) {
|
||||
const newId = c.type === "bundle" ? innerBundleMap.get(id) : moduleMap.get(id);
|
||||
if (newId) next = { ...next, id: newId };
|
||||
|
||||
// (1b) Sync the child snapshot's `payload_hash` to the restored library
|
||||
// entry's LIVE hash. `toChildSnapshot` froze the stale pre-cascade hash;
|
||||
// for a restored module whose refs were corrected (Phase 1 Pass-2) the
|
||||
// payload moved but the frozen hash didn't, so the pushed bundle would
|
||||
// ship a child whose payload ≠ its payload_hash → RE-INSERTING the bundle
|
||||
// shows spurious DRIFT against the live library entry. `moduleHashMap` is
|
||||
// keyed by OLD id and holds only restored modules (POST hash, overridden
|
||||
// by the corrective-update hash), so non-restored + bundle children are
|
||||
// left untouched.
|
||||
const freshHash = moduleHashMap?.get(id);
|
||||
if (freshHash) next = { ...next, payload_hash: freshHash };
|
||||
}
|
||||
const next = moduleMap.get(id);
|
||||
return next ? { ...c, id: next } : c;
|
||||
|
||||
// (2) Inner references (constraint source/target, @{} refs) → restored ids.
|
||||
const table: Record<string, string> = {};
|
||||
for (const [k, v] of moduleMap) table[k] = v;
|
||||
for (const [k, v] of innerBundleMap) table[k] = v;
|
||||
if (Object.keys(table).length > 0) {
|
||||
if (next.payload && typeof next.payload === "object") {
|
||||
next = { ...next, payload: walkRemap(next.payload, table) as Record<string, unknown> };
|
||||
}
|
||||
if (next.instance && typeof next.instance === "object") {
|
||||
next = { ...next, instance: walkRemap(next.instance, table) as Record<string, unknown> };
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Pre-scan to count what cascade would heal — used by the modal to
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractBundleChildren, type ExtractableChild } from "./extract-to-library";
|
||||
|
||||
// Deterministic id minter for tests: a1→n1, etc., in call order.
|
||||
function seqMinter(): () => string {
|
||||
let i = 0;
|
||||
return () => `new${++i}`;
|
||||
}
|
||||
|
||||
const wc = (id: string, value: string): ExtractableChild => ({
|
||||
id, type: "wildcard", meta: { name: `wc-${id}` },
|
||||
payload: { options: [{ id: "o1", value }] },
|
||||
});
|
||||
|
||||
describe("extractBundleChildren", () => {
|
||||
it("mints fresh ids and remaps an intra-bundle constraint's source/target", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
wc("aaaa1111", "plain"),
|
||||
wc("bbbb2222", "plain"),
|
||||
{ id: "cccc3333", type: "constraint", meta: { name: "pair" },
|
||||
payload: { source_wildcard_id: "aaaa1111", target_wildcard_id: "bbbb2222", matrix: {}, exceptions: [] } },
|
||||
];
|
||||
const { modules, remap, skipped } = extractBundleChildren(children, seqMinter());
|
||||
expect(skipped).toBe(0);
|
||||
expect(modules).toHaveLength(3);
|
||||
expect(remap).toEqual({ aaaa1111: "new1", bbbb2222: "new2", cccc3333: "new3" });
|
||||
const con = modules.find((m) => m.type === "constraint")!;
|
||||
expect(con.id).toBe("new3");
|
||||
expect(con.payload.source_wildcard_id).toBe("new1");
|
||||
expect(con.payload.target_wildcard_id).toBe("new2");
|
||||
expect(con.name).toBe("pair");
|
||||
});
|
||||
|
||||
it("remaps a nested @{} ref pointing at a sibling child", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
wc("aaaa1111", "plain"),
|
||||
wc("bbbb2222", "see @{aaaa1111} here"),
|
||||
];
|
||||
const { modules } = extractBundleChildren(children, seqMinter());
|
||||
const b = modules.find((m) => m.name === "wc-bbbb2222")!;
|
||||
expect(b.payload.options).toEqual([{ id: "o1", value: "see @{new1} here" }]);
|
||||
});
|
||||
|
||||
it("leaves refs pointing OUTSIDE the bundle verbatim", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
{ id: "cccc3333", type: "constraint", meta: { name: "pair" },
|
||||
payload: { source_wildcard_id: "zzzz9999", target_wildcard_id: "zzzz9999", matrix: {}, exceptions: [] } },
|
||||
];
|
||||
const { modules } = extractBundleChildren(children, seqMinter());
|
||||
expect(modules[0].payload.source_wildcard_id).toBe("zzzz9999");
|
||||
});
|
||||
|
||||
it("skips bundle-typed children (counts them) and extracts only leaf modules", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
wc("aaaa1111", "plain"),
|
||||
{ id: "dddd4444", type: "bundle", meta: { name: "nested" }, payload: {} },
|
||||
];
|
||||
const { modules, skipped } = extractBundleChildren(children, seqMinter());
|
||||
expect(skipped).toBe(1);
|
||||
expect(modules).toHaveLength(1);
|
||||
expect(modules[0].type).toBe("wildcard");
|
||||
});
|
||||
|
||||
it("carries name from meta.name, falling back to the id", () => {
|
||||
const children: ExtractableChild[] = [
|
||||
{ id: "aaaa1111", type: "wildcard", payload: { options: [] } },
|
||||
];
|
||||
const { modules } = extractBundleChildren(children, seqMinter());
|
||||
expect(modules[0].name).toBe("aaaa1111");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Bundle "extract to library" (Feature 4).
|
||||
*
|
||||
* Pure transform: take a bundle's frozen child snapshots and produce
|
||||
* standalone library-module entities with FRESH ids, remapping every
|
||||
* intra-bundle reference (constraint source/target ids + nested `@{uuid}`)
|
||||
* to the new ids. References pointing OUTSIDE the extracted set stay
|
||||
* verbatim (they'll resolve against whatever lives in the target library,
|
||||
* or become ordinary danglers eligible for reattach).
|
||||
*
|
||||
* Bundle-typed children are skipped (a nested bundle isn't a leaf module);
|
||||
* the count is returned so the caller can surface "N skipped". Per-instance
|
||||
* overrides are intentionally NOT extracted — a library module is the
|
||||
* definition (payload), instance overrides are graph-specific.
|
||||
*
|
||||
* No I/O. The id minter is injected (defaults to `newShortId`) so tests can
|
||||
* assert deterministic remaps.
|
||||
*/
|
||||
import { newShortId } from "../../../manager/utils/ids";
|
||||
import { walkRemap } from "./uuid-remap";
|
||||
|
||||
/** Minimal child-snapshot shape this reads. Bundle children carry more
|
||||
* (`instance`, `enabled`, `payload_hash`…) — ignored here. */
|
||||
export interface ExtractableChild {
|
||||
id: string;
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
meta?: { name?: string; description?: string; tags?: string[] };
|
||||
}
|
||||
|
||||
/** A standalone library-module entity ready to install (engine-row shape). */
|
||||
export interface ExtractedModule {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExtractResult {
|
||||
modules: ExtractedModule[];
|
||||
/** oldChildId → freshId, for every extracted leaf. */
|
||||
remap: Record<string, string>;
|
||||
/** count of bundle-typed children skipped. */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export function extractBundleChildren(
|
||||
children: ExtractableChild[],
|
||||
mintId: () => string = newShortId,
|
||||
): ExtractResult {
|
||||
const leaves = children.filter((c) => c.type !== "bundle");
|
||||
const skipped = children.length - leaves.length;
|
||||
|
||||
// Pass 1: mint a fresh id for every leaf so intra-bundle refs can resolve
|
||||
// against the COMPLETE table (a constraint's sibling may come later).
|
||||
const remap: Record<string, string> = {};
|
||||
for (const c of leaves) remap[c.id] = mintId();
|
||||
|
||||
// Pass 2: walkRemap each payload with the full table + lift meta → fields.
|
||||
const modules: ExtractedModule[] = leaves.map((c) => {
|
||||
const mod: ExtractedModule = {
|
||||
id: remap[c.id],
|
||||
type: c.type,
|
||||
name: c.meta?.name ?? c.id,
|
||||
payload: walkRemap(c.payload ?? {}, remap) as Record<string, unknown>,
|
||||
};
|
||||
if (c.meta?.description) mod.description = c.meta.description;
|
||||
if (c.meta?.tags && c.meta.tags.length > 0) mod.tags = c.meta.tags;
|
||||
return mod;
|
||||
});
|
||||
|
||||
return { modules, remap, skipped };
|
||||
}
|
||||
@@ -1,182 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { remapBundleUuids, type ChildSnapshot } from "./uuid-remap";
|
||||
import { walkRemap } from "./uuid-remap";
|
||||
|
||||
describe("remapBundleUuids — id regeneration", () => {
|
||||
it("regenerates every child id to a fresh 12-char hex", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aaaa1111", type: "wildcard" },
|
||||
{ id: "bbbb2222", type: "constraint" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
expect(result.children).toHaveLength(2);
|
||||
expect(result.children[0].id).not.toBe("aaaa1111");
|
||||
expect(result.children[1].id).not.toBe("bbbb2222");
|
||||
expect(result.children[0].id).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(result.children[1].id).toMatch(/^[0-9a-f]{12}$/);
|
||||
describe("walkRemap — partial remap table", () => {
|
||||
it("rewrites a whole-string field whose value is a remapped id", () => {
|
||||
const out = walkRemap("src11111", { src11111: "newsrc01" });
|
||||
expect(out).toBe("newsrc01");
|
||||
});
|
||||
|
||||
it("remap table maps every old id to its fresh id", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aaaa1111", type: "wildcard" },
|
||||
{ id: "bbbb2222", type: "constraint" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
expect(result.remap.aaaa1111).toBe(result.children[0].id);
|
||||
expect(result.remap.bbbb2222).toBe(result.children[1].id);
|
||||
expect(Object.keys(result.remap)).toHaveLength(2);
|
||||
it("leaves a whole-string field untouched when not in the remap table", () => {
|
||||
const out = walkRemap("other999", { src11111: "newsrc01" });
|
||||
expect(out).toBe("other999");
|
||||
});
|
||||
|
||||
it("does not mutate input children", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aaaa1111", type: "wildcard", payload: { var_binding: "x" } },
|
||||
];
|
||||
const snapshot = JSON.parse(JSON.stringify(children));
|
||||
remapBundleUuids(children);
|
||||
expect(children).toEqual(snapshot);
|
||||
it("rewrites an embedded @{uuid} ref when the uuid is in the table", () => {
|
||||
const out = walkRemap("see @{bbbb2222} here", { bbbb2222: "newbbbb1" });
|
||||
expect(out).toBe("see @{newbbbb1} here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapBundleUuids — @{uuid} text refs", () => {
|
||||
it("rewrites @{uuid} in nested payload string values when uuid is in remap", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{
|
||||
id: "aaaa1111",
|
||||
type: "wildcard",
|
||||
payload: { options: [{ value: "see @{bbbb2222} here" }] },
|
||||
},
|
||||
{ id: "bbbb2222", type: "wildcard" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const newBbbb = result.remap.bbbb2222;
|
||||
const opt = (result.children[0].payload as Record<string, unknown>).options as Array<{ value: string }>;
|
||||
expect(opt[0].value).toBe(`see @{${newBbbb}} here`);
|
||||
it("preserves the #name and :subcat segments of an @{} ref", () => {
|
||||
const out = walkRemap("@{bbbb2222#Hair:long}", { bbbb2222: "newbbbb1" });
|
||||
expect(out).toBe("@{newbbbb1#Hair:long}");
|
||||
});
|
||||
|
||||
it("rewrites multiple @{uuid} refs in the same string", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{
|
||||
id: "aaaa1111",
|
||||
type: "combine",
|
||||
payload: { template: "$a @{bbbb2222} $b @{cccc3333} $c" },
|
||||
},
|
||||
{ id: "bbbb2222", type: "wildcard" },
|
||||
{ id: "cccc3333", type: "wildcard" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const tpl = (result.children[0].payload as Record<string, string>).template;
|
||||
expect(tpl).toBe(`$a @{${result.remap.bbbb2222}} $b @{${result.remap.cccc3333}} $c`);
|
||||
it("leaves @{uuid} refs to non-remapped uuids verbatim", () => {
|
||||
const out = walkRemap("see @{xxxx9999} here", { bbbb2222: "newbbbb1" });
|
||||
expect(out).toBe("see @{xxxx9999} here");
|
||||
});
|
||||
|
||||
it("leaves @{uuid} refs to non-bundle uuids untouched", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{
|
||||
id: "aaaa1111",
|
||||
type: "wildcard",
|
||||
payload: { options: [{ value: "see @{xxxx9999} here" }] },
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const opt = (result.children[0].payload as Record<string, unknown>).options as Array<{ value: string }>;
|
||||
expect(opt[0].value).toBe("see @{xxxx9999} here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapBundleUuids — constraint refs", () => {
|
||||
it("rewrites source_wildcard_id + target_wildcard_id when both are in bundle", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aabbcc11", type: "wildcard" },
|
||||
{ id: "ddeeff22", type: "wildcard" },
|
||||
{
|
||||
id: "11223344",
|
||||
type: "constraint",
|
||||
payload: {
|
||||
source_wildcard_id: "aabbcc11",
|
||||
target_wildcard_id: "ddeeff22",
|
||||
matrix: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const con = result.children[2].payload as Record<string, string>;
|
||||
expect(con.source_wildcard_id).toBe(result.remap.aabbcc11);
|
||||
expect(con.target_wildcard_id).toBe(result.remap.ddeeff22);
|
||||
});
|
||||
|
||||
it("leaves source/target unchanged when they reference modules outside the bundle", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{
|
||||
id: "11223344",
|
||||
type: "constraint",
|
||||
payload: {
|
||||
source_wildcard_id: "99887766",
|
||||
target_wildcard_id: "55443322",
|
||||
matrix: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const con = result.children[0].payload as Record<string, string>;
|
||||
expect(con.source_wildcard_id).toBe("99887766");
|
||||
expect(con.target_wildcard_id).toBe("55443322");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapBundleUuids — instance fields", () => {
|
||||
it("rewrites uuid-valued instance fields too", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aabbcc11", type: "wildcard" },
|
||||
{
|
||||
id: "11223344",
|
||||
type: "constraint",
|
||||
instance: {
|
||||
// Bare-string uuid reference — gets rewritten via remap
|
||||
// table lookup (full-string match).
|
||||
pinned_source: "aabbcc11",
|
||||
// Text containing `@{uuid}` — gets rewritten via the
|
||||
// embedded-ref regex pass.
|
||||
tooltip: "Pins to @{aabbcc11}",
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const inst = result.children[1].instance as Record<string, string>;
|
||||
expect(inst.pinned_source).toBe(result.remap.aabbcc11);
|
||||
expect(inst.tooltip).toBe(`Pins to @{${result.remap.aabbcc11}}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapBundleUuids — edge cases", () => {
|
||||
it("handles empty children list", () => {
|
||||
const result = remapBundleUuids([]);
|
||||
expect(result.children).toEqual([]);
|
||||
expect(result.remap).toEqual({});
|
||||
});
|
||||
|
||||
it("handles children with no payload or instance", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "aaaa1111", type: "wildcard" },
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
expect(result.children[0].payload).toBeUndefined();
|
||||
expect(result.children[0].instance).toBeUndefined();
|
||||
expect(result.children[0].id).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("recurses into arrays of arrays of objects", () => {
|
||||
const children: ChildSnapshot[] = [
|
||||
{ id: "abcdef01", type: "x" },
|
||||
{
|
||||
id: "abcdef02",
|
||||
type: "wildcard",
|
||||
payload: {
|
||||
deeply: [[{ ref: "abcdef01", text: "see @{abcdef01}" }]],
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = remapBundleUuids(children);
|
||||
const newId = result.remap.abcdef01;
|
||||
const deeply = (result.children[1].payload as Record<string, unknown>).deeply as Array<Array<{ ref: string; text: string }>>;
|
||||
expect(deeply[0][0].ref).toBe(newId);
|
||||
expect(deeply[0][0].text).toBe(`see @{${newId}}`);
|
||||
it("recurses into nested arrays + objects without mutating input", () => {
|
||||
const input = { deeply: [[{ ref: "abcdef01", text: "see @{abcdef01}" }]] };
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
const out = walkRemap(input, { abcdef01: "newid001" }) as {
|
||||
deeply: Array<Array<{ ref: string; text: string }>>;
|
||||
};
|
||||
expect(out.deeply[0][0].ref).toBe("newid001");
|
||||
expect(out.deeply[0][0].text).toBe("see @{newid001}");
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
* Pure-TS, no engine imports. Safe to import from frontend bundle code.
|
||||
*/
|
||||
|
||||
import { newRowUid } from "../../../widgets/_shared";
|
||||
|
||||
export interface ChildSnapshot {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -46,8 +44,10 @@ export interface RemapResult {
|
||||
const REF_RE = /@\{([0-9a-f]{6,16})(#[^#:}@{]*)?(:[^}]*)?\}/gi;
|
||||
|
||||
/** Deep walks an unknown value, applying the remap. Returns a new
|
||||
* object — never mutates input. */
|
||||
function walkRemap(value: unknown, remap: Record<string, string>): unknown {
|
||||
* object — never mutates input. Exported so the cascade-restore path
|
||||
* can re-point constraint source/target + `@{}` refs at restored module
|
||||
* uuids using a partial remap table (not a full id regen). */
|
||||
export function walkRemap(value: unknown, remap: Record<string, string>): unknown {
|
||||
if (typeof value === "string") {
|
||||
let out = value;
|
||||
// Whole-string match — e.g. constraint.source_wildcard_id = "src11111"
|
||||
@@ -77,25 +77,3 @@ function walkRemap(value: unknown, remap: Record<string, string>): unknown {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Regenerate every child uuid in the snapshot list, then deep-walk
|
||||
* every child's `payload` + `instance` rewriting any references to
|
||||
* match. Constraint source/target ids + `@{uuid}` text refs all flow
|
||||
* through `walkRemap` since they live inside `payload` / `instance`. */
|
||||
export function remapBundleUuids(children: ChildSnapshot[]): RemapResult {
|
||||
const remap: Record<string, string> = {};
|
||||
for (const c of children) {
|
||||
remap[c.id] = newRowUid();
|
||||
}
|
||||
const remapped: ChildSnapshot[] = children.map((c) => {
|
||||
const next: ChildSnapshot = { ...c, id: remap[c.id] };
|
||||
if (c.payload) {
|
||||
next.payload = walkRemap(c.payload, remap) as Record<string, unknown>;
|
||||
}
|
||||
if (c.instance) {
|
||||
next.instance = walkRemap(c.instance, remap) as Record<string, unknown>;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return { children: remapped, remap };
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ beforeEach(() => {
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-a", bbbbbbbb: "h-b" } }), { status: 200 });
|
||||
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-a" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-b" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
@@ -32,7 +32,7 @@ describe("drift-store: subscribe/unsubscribe", () => {
|
||||
expect(hashes.value).toBeNull();
|
||||
subscribe();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hashes.value).toEqual({ aaaaaaaa: "h-a", bbbbbbbb: "h-b" });
|
||||
expect(hashes.value).toEqual({ aaaaaaaa: { type: "wildcard", payload_hash: "h-a" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-b" } });
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe("drift-store: forceRefresh", () => {
|
||||
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import type { SnapshotEntry } from "./drift-store";
|
||||
import { refreshMany } from "./drift-store";
|
||||
import { refreshMany, mergeRefresh } from "./drift-store";
|
||||
|
||||
function mkEntry(over: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
@@ -250,7 +250,8 @@ describe("setLibraryHash — direct hash update without re-fetch", () => {
|
||||
setLibraryHash("abc123", "newhash456");
|
||||
expect(hashes.value).not.toBeNull();
|
||||
if (hashes.value) {
|
||||
expect(hashes.value["abc123"]).toBe("newhash456");
|
||||
// Optimistic entry stores { payload_hash } (type filled by next poll).
|
||||
expect(hashes.value["abc123"]).toEqual({ payload_hash: "newhash456" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -258,6 +259,43 @@ describe("setLibraryHash — direct hash update without re-fetch", () => {
|
||||
setLibraryHash("a", "ha");
|
||||
setLibraryHash("b", "hb");
|
||||
setLibraryHash("a", "ha-updated");
|
||||
expect(hashes.value).toEqual({ a: "ha-updated", b: "hb" });
|
||||
expect(hashes.value).toEqual({
|
||||
a: { payload_hash: "ha-updated" },
|
||||
b: { payload_hash: "hb" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("drift-store: mergeRefresh type-guard", () => {
|
||||
it("throws when the live snapshot is a DIFFERENT kind than the embedded row", () => {
|
||||
const embedded = mkEntry({ id: "aabbccdd", type: "constraint" });
|
||||
const live: SnapshotEntry = {
|
||||
snapshot_version: 1,
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "w",
|
||||
payload: {},
|
||||
payload_hash: "h2",
|
||||
source: { kind: "user" },
|
||||
};
|
||||
// A same-id, different-kind library row must never merge over the
|
||||
// embedded item — it would silently change the item's kind.
|
||||
expect(() => mergeRefresh(embedded, live)).toThrow(/type mismatch/i);
|
||||
});
|
||||
|
||||
it("merges normally when the kinds match", () => {
|
||||
const embedded = mkEntry({ id: "aabbccdd", type: "wildcard" });
|
||||
const live: SnapshotEntry = {
|
||||
snapshot_version: 1,
|
||||
uuid: "aabbccdd",
|
||||
type: "wildcard",
|
||||
name: "w2",
|
||||
payload: { options: [] },
|
||||
payload_hash: "h2",
|
||||
source: { kind: "user" },
|
||||
};
|
||||
const merged = mergeRefresh(embedded, live);
|
||||
expect(merged.type).toBe("wildcard");
|
||||
expect(merged.payload_hash).toBe("h2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,16 +20,18 @@ export interface RefreshResult {
|
||||
failed: { id: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** Live-library hashes keyed by uuid. `null` while the first fetch is in
|
||||
/** Live-library identity map keyed by uuid: each value carries the row's
|
||||
* `{type, payload_hash}`. `type` enables cross-kind id-clash detection;
|
||||
* `payload_hash` is the drift signal. `null` while the first fetch is in
|
||||
* flight — predicates downstream use this to avoid flashing "missing" /
|
||||
* "drift" before we know the truth.
|
||||
*
|
||||
* Invariant: values are always non-empty strings once the map is non-null.
|
||||
* Consumers may assume `live === undefined` is equivalent to `!(uuid in
|
||||
* hashes.value)` — the API never emits explicit-undefined entries. If this
|
||||
* ever changes, both `isMissingFromLibrary` and `isDrifted` in
|
||||
* `ContextWidget.vue` need to be tightened together. */
|
||||
export const hashes: Ref<Record<string, string> | null> = ref(null);
|
||||
* `type` is optional because the optimistic `setLibraryHash` writes a
|
||||
* payload_hash-only entry (the 5s poll fills `type` shortly after).
|
||||
* Consumers run `classifyOne` (import-export/collision) against these
|
||||
* entries: `live === undefined` ≡ `!(uuid in hashes.value)` — the API
|
||||
* never emits explicit-undefined entries. */
|
||||
export const hashes: Ref<Record<string, { type?: string; payload_hash: string }> | null> = ref(null);
|
||||
|
||||
/** Same shape as `hashes` but keyed by bundle library uuid. Polled
|
||||
* in parallel with module hashes so bundle drift detection
|
||||
@@ -94,13 +96,35 @@ function sameHashes(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Object-aware sibling of `sameHashes` for the module map, whose values
|
||||
* are `{type, payload_hash}` rather than flat strings. Same purpose: gate
|
||||
* `hashes.value` reassignment so the 5s poll doesn't mint a new ref (and
|
||||
* fire reactivity) when nothing changed. */
|
||||
function sameModuleHashes(
|
||||
a: Record<string, { type?: string; payload_hash: string }> | null,
|
||||
b: Record<string, { type?: string; payload_hash: string }>,
|
||||
): boolean {
|
||||
if (a === null) return false;
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
for (const k of keysA) {
|
||||
const va = a[k];
|
||||
const vb = b[k];
|
||||
if (!vb || va.type !== vb.type || va.payload_hash !== vb.payload_hash) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchModuleHashes(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/wp/api/modules/hashes");
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { hashes?: Record<string, string> };
|
||||
const body = (await res.json()) as {
|
||||
hashes?: Record<string, { type?: string; payload_hash: string }>;
|
||||
};
|
||||
if (body && body.hashes && typeof body.hashes === "object") {
|
||||
if (!sameHashes(hashes.value, body.hashes)) {
|
||||
if (!sameModuleHashes(hashes.value, body.hashes)) {
|
||||
hashes.value = body.hashes;
|
||||
}
|
||||
}
|
||||
@@ -189,6 +213,15 @@ export async function refreshMany(modules: ModuleEntry[]): Promise<RefreshResult
|
||||
* actually move.
|
||||
*/
|
||||
export function mergeRefresh(m: ModuleEntry, live: SnapshotEntry): ModuleEntry {
|
||||
if (m.type !== live.type) {
|
||||
// Defense-in-depth: a same-id, DIFFERENT-kind library row must never be
|
||||
// merged over an embedded item (it would silently change the item's
|
||||
// kind). The UI suppresses Refresh for type-conflict rows; this guards
|
||||
// the path even if a caller bypasses that.
|
||||
throw new Error(
|
||||
`refresh type mismatch: embedded ${m.type} vs library ${live.type} at id ${m.id}`,
|
||||
);
|
||||
}
|
||||
const merged: ModuleEntry & { bundle_origin?: string } = {
|
||||
type: live.type,
|
||||
meta: {
|
||||
@@ -220,8 +253,12 @@ export function mergeRefresh(m: ModuleEntry, live: SnapshotEntry): ModuleEntry {
|
||||
*
|
||||
* See: docs/superpowers/specs/2026-05-07-instance-overrides-modal-design.md §8.5
|
||||
*/
|
||||
export function setLibraryHash(id: string, hash: string): void {
|
||||
hashes.value = { ...(hashes.value ?? {}), [id]: hash };
|
||||
export function setLibraryHash(id: string, payloadHash: string): void {
|
||||
// Optimistic entry — `type` omitted (the 5s poll fills it). Enough to
|
||||
// clear the missing/drift dot immediately: classifyOne sees the id
|
||||
// present + payload_hash match → silent-skip. A type-conflict can't
|
||||
// arise here (you're saving your own entry), so omitting type is safe.
|
||||
hashes.value = { ...(hashes.value ?? {}), [id]: { payload_hash: payloadHash } };
|
||||
}
|
||||
|
||||
/** Test-only — reset internal counters between cases. */
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import InstanceIdChip from "./InstanceIdChip.vue";
|
||||
|
||||
describe("InstanceIdChip", () => {
|
||||
beforeEach(() => {
|
||||
// jsdom has no clipboard by default — stub a resolving writeText.
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: vi.fn(async () => undefined) },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the library id as the visible label", () => {
|
||||
const w = mount(InstanceIdChip, { props: { id: "ab12cd34" } });
|
||||
const chip = w.get('[data-test="instance-id-chip"]');
|
||||
expect(chip.text()).toContain("ab12cd34");
|
||||
});
|
||||
|
||||
it("renders nothing when id is empty (unlinked/inline module)", () => {
|
||||
const w = mount(InstanceIdChip, { props: { id: "" } });
|
||||
expect(w.find('[data-test="instance-id-chip"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces both id and _uid in the tooltip", () => {
|
||||
const w = mount(InstanceIdChip, { props: { id: "ab12cd34", uid: "uid-9f9f9f" } });
|
||||
const title = w.get('[data-test="instance-id-chip"]').attributes("title") ?? "";
|
||||
expect(title).toContain("ab12cd34");
|
||||
expect(title).toContain("uid-9f9f9f");
|
||||
});
|
||||
|
||||
it("copies the id to the clipboard and flips to a copied state on click", async () => {
|
||||
const writeText = navigator.clipboard.writeText as ReturnType<typeof vi.fn>;
|
||||
const w = mount(InstanceIdChip, { props: { id: "ab12cd34" } });
|
||||
await w.get('[data-test="instance-id-chip"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(writeText).toHaveBeenCalledWith("ab12cd34");
|
||||
expect(w.get('[data-test="instance-id-chip"]').classes()).toContain("wp-idchip--copied");
|
||||
expect(w.get('[data-test="instance-id-chip"]').text()).toContain("copied");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Small monospace, click-to-copy chip that surfaces a module/bundle's
|
||||
* identity in the edit-modal header. Debugging aid (workstream C / issue #4):
|
||||
* the library `id` is the uuid that `@{id}` nested refs, constraint
|
||||
* source/target, and library rows all match on — so seeing it (and being
|
||||
* able to copy it) makes drift/linking bugs traceable at a glance.
|
||||
*
|
||||
* Shows the library `id` as the visible label; the instance `_uid` (what
|
||||
* constraint `pick` + `bundle_origin` key on) rides along in the tooltip so
|
||||
* both identities are one hover away without cluttering the header.
|
||||
*/
|
||||
import { ref, onBeforeUnmount } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Library uuid — the payload row id. Matches `@{id}`, constraint
|
||||
* source/target ids, and library rows. Empty for inline-only modules. */
|
||||
id: string;
|
||||
/** Instance uid — per-Context-node instance identity used by constraint
|
||||
* `pick` selectors + `bundle_origin`. Optional (absent on some surfaces). */
|
||||
uid?: string;
|
||||
}>();
|
||||
|
||||
const copied = ref(false);
|
||||
let timer: number | undefined;
|
||||
|
||||
async function copyId(): Promise<void> {
|
||||
if (!props.id) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.id);
|
||||
copied.value = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => { copied.value = false; }, 1200);
|
||||
} catch {
|
||||
// Clipboard blocked (insecure context / denied) — the id stays visible
|
||||
// for manual selection, so a failed copy is a silent no-op.
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="id"
|
||||
type="button"
|
||||
class="wp-idchip"
|
||||
:class="{ 'wp-idchip--copied': copied }"
|
||||
:title="`library id: ${id}${uid ? `\ninstance uid: ${uid}` : ''}\n(click to copy id)`"
|
||||
data-test="instance-id-chip"
|
||||
@click.stop="copyId"
|
||||
>
|
||||
<i
|
||||
class="pi wp-idchip__icon"
|
||||
:class="copied ? 'pi-check' : 'pi-hashtag'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="wp-idchip__id">{{ copied ? "copied" : id }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-idchip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--wp-border, #33333f);
|
||||
border-radius: var(--wp-radius-sm, 4px);
|
||||
background: var(--wp-bg2, #1b1b22);
|
||||
color: var(--wp-text-dim, #8a8a99);
|
||||
font: 500 10px/1.4 var(--wp-font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.12s, border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.wp-idchip:hover {
|
||||
color: var(--wp-text, #e7e7ee);
|
||||
border-color: color-mix(in oklab, var(--wp-accent, #7c6ad6) 55%, var(--wp-border, #33333f));
|
||||
}
|
||||
.wp-idchip__icon {
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.wp-idchip__id {
|
||||
font-family: inherit;
|
||||
}
|
||||
.wp-idchip--copied {
|
||||
color: var(--wp-green, #6ad28b);
|
||||
border-color: color-mix(in oklab, var(--wp-green, #6ad28b) 55%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,12 @@
|
||||
.wp-wcm__head { --modal-accent: var(--wp-kind-wildcard); }
|
||||
|
||||
.wp-cbm__head, .wp-cnm__head, .wp-dvm__head, .wp-fvm__head, .wp-wcm__head {
|
||||
/* Pinned to the top of the scrolling modal body so the title + close
|
||||
* stay visible while the content scrolls. Opaque (gradient bottoms out
|
||||
* at --wp-bg2) so scrolled content doesn't bleed through. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user